# -*- coding: utf-8 -*-
"""MESSENGER.PY — kênh Facebook Messenger, dùng CHUNG lõi + tri thức với web.

VÌ SAO (24/09/2026)
-------------------
Chủ dự án: "soạn lại phần chat bot này để vừa hỗ trợ được kênh chat bot
trên fanpage facebook vừa hỗ trợ tại website". Lõi (nao/tra_loi.py) vốn không
biết kênh — tệp này chỉ là một CỔNG: lấy tin từ hàng chờ, gọi đúng hàm lõi
web đang dùng, định dạng lại cho Messenger rồi gửi qua Graph API.

LUỒNG
-----
public/api/messenger.php (webhook) ghi tin vào `chat_hang_cho` và gọi
/kenh/danh-thuc. Luồng nền ở đây:
  1. lấy các khách có tin TỚI HẠN (han <= NOW());
  2. NHƯỜNG NGƯỜI THẬT: nhân viên đã nhắn tay cho khách SAU tin đầu tiên
     trong lô -> bỏ qua cả lô (người thật đang lo). Tin chỉ bị hoãn khi nhân
     viên đang tiếp khách đó; hết hạn mà nhân viên không trả lời -> bot tiếp
     quản (chủ dự án: "trong vòng x phút tôi ko trả lời lại thì tiếp quản lại
     và tư vấn tiếp");
  3. gộp các tin khách gửi liền nhau thành một câu (khách Messenger hay gõ
     từng mẩu ngắn);
  4. nhân viên vừa nhắn gì thì đưa cho mô hình biết (tiếp nối, không nói
     ngược);
  5. gọi tra_loi() với NguCanh(kenh='messenger'), định dạng, gửi;
  6. bot phải chuyển người / khách xin gặp người / để lại số -> mở PHIẾU
     (kenh/phieu.py): thẻ Telegram có nút, khách được dặn chờ ngay tại đây.

Chạy tự kiểm (không cần CSDL, không gọi mạng):  python kenh/messenger.py
"""
from __future__ import annotations

import concurrent.futures
import json
import logging
import os
import re
import sys
import threading
import time
from typing import Any, Callable, Dict, List, Optional, Tuple

try:
    from giao_uoc import NguCanh
    from congcu import csdl
except ImportError:
    _THU_MUC_CHA = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    if _THU_MUC_CHA not in sys.path:
        sys.path.insert(0, _THU_MUC_CHA)
    from giao_uoc import NguCanh  # type: ignore
    from congcu import csdl  # type: ignore

_log = logging.getLogger('chatbot.kenh.messenger')

GRAPH = 'https://graph.facebook.com/v24.0'
DAU_BOT = 'tabot'          # metadata gắn vào tin bot gửi — webhook nhận echo có dấu này thì biết là của bot
TOI_DA_KY_TU = 1900        # Messenger giới hạn 2000 ký tự mỗi tin
TOI_DA_CAU_HOI = 500       # tra_loi() từ chối câu > 500 ký tự
CHU_KY_GIAY = 10
SO_LUONG = 3               # xử lý song song tối đa bấy nhiêu khách


# =====================================================================
# ĐỊNH DẠNG CHO MESSENGER
# =====================================================================

def dinh_dang(van_ban: str) -> str:
    """Markdown gọn -> chữ thuần Messenger. Câu dẫn đã dặn mô hình không dùng
    markdown; tầng này gỡ nốt phần sót (và câu tầng 1 chủ viết bằng markdown)."""
    s = (van_ban or '').replace('\r\n', '\n')
    s = re.sub(r'\*\*(.+?)\*\*', r'\1', s)
    s = re.sub(r'`([^`\n]+)`', r'\1', s)
    s = re.sub(r'^\s*#{1,6}\s+', '', s, flags=re.M)
    s = re.sub(r'^(\s*)[-*]\s+', r'\1• ', s, flags=re.M)
    s = re.sub(r'\n{3,}', '\n\n', s)
    return s.strip()


def chia_tin(van_ban: str, toi_da: int = TOI_DA_KY_TU) -> List[str]:
    """Chia thành các tin <= toi_da ký tự, cắt ở ranh giới đoạn / dòng."""
    van_ban = (van_ban or '').strip()
    if len(van_ban) <= toi_da:
        return [van_ban] if van_ban else []
    ds: List[str] = []
    hien = ''
    for doan in van_ban.split('\n\n'):
        ung = (hien + '\n\n' + doan) if hien else doan
        if len(ung) <= toi_da:
            hien = ung
            continue
        if hien:
            ds.append(hien)
        while len(doan) > toi_da:                       # một đoạn quá dài: cắt theo dòng / cứng
            cat = doan.rfind('\n', 0, toi_da)
            cat = cat if cat > toi_da // 2 else toi_da
            ds.append(doan[:cat].rstrip())
            doan = doan[cat:].lstrip()
        hien = doan
    if hien:
        ds.append(hien)
    return ds


# =====================================================================
# GRAPH API
# =====================================================================

def _post_graph(duong: str, token: str, du_lieu: Dict[str, Any]) -> Tuple[bool, str]:
    import httpx
    try:
        r = httpx.post(f'{GRAPH}/{duong}', headers={'Authorization': f'Bearer {token}'}, json=du_lieu, timeout=15)
        if r.status_code == 200:
            return True, ''
        return False, f'HTTP {r.status_code}: {r.text[:250]}'
    except Exception as loi:  # noqa: BLE001
        return False, f'Lỗi mạng: {loi}'


def dang_go(kenh: Dict[str, Any], psid: str) -> None:
    """Bật "đang soạn tin…" phía khách (Messenger tự tắt sau ~20 giây hoặc khi có tin)."""
    token = str(kenh.get('page_token') or '')
    if token:
        _post_graph('me/messages', token, {'recipient': {'id': psid}, 'sender_action': 'typing_on'})


def gui(kenh: Dict[str, Any], psid: str, van_ban: str,
        _post: Optional[Callable[[str, str, Dict[str, Any]], Tuple[bool, str]]] = None,
        tra_loi_mid: Optional[str] = None, trich: Optional[str] = None,
        nut: Optional[List[str]] = None, nhan_tag: Optional[str] = None) -> Tuple[bool, str]:
    """Gửi câu trả lời (đã định dạng) cho một khách. Trả (ok, lỗi).

    `tra_loi_mid`: trả lời TRÍCH DẪN đúng tin của khách (Messenger hiện tin gốc
    phía trên). Meta từ chối trích dẫn thì gửi lại thường, đầu tin ghi
    "↪ «trich»" để khách vẫn biết câu này đáp tin nào.
    `nut` (01/10/2026): nút chọn nhanh (quick reply) gắn vào tin CUỐI — khách bấm là gửi đúng chữ trên nút.
    `nhan_tag`: gửi ngoài cửa sổ 24 giờ bằng nhãn Meta (vd 'HUMAN_AGENT' — chỉ cho tin NGƯỜI THẬT duyệt,
    tối đa 7 ngày từ tin cuối của khách); không có thì 'RESPONSE' như cũ."""
    post = _post or _post_graph
    token = str(kenh.get('page_token') or '')
    if not token:
        return False, 'Kênh chưa có Page Access Token.'
    post('me/messages', token, {'recipient': {'id': psid}, 'sender_action': 'typing_on'})
    cac_phan = chia_tin(dinh_dang(van_ban))
    for i, phan in enumerate(cac_phan):
        goi = {
            'recipient': {'id': psid},
            'messaging_type': 'MESSAGE_TAG' if nhan_tag else 'RESPONSE',
            'message': {'text': phan, 'metadata': DAU_BOT},
        }
        if nhan_tag:
            goi['tag'] = nhan_tag
        if nut and i == len(cac_phan) - 1:
            goi['message']['quick_replies'] = [{'content_type': 'text', 'title': t[:20], 'payload': t[:200]}
                                               for t in nut[:5] if t and t.strip()]
        if i == 0 and tra_loi_mid:
            ok, loi = post('me/messages', token, dict(goi, reply_to={'mid': tra_loi_mid}))
            if ok:
                continue
            _log.warning('Meta từ chối trả lời trích dẫn (%s) — gửi kèm dòng trích bằng chữ.', loi)
            if trich:
                ngan = ' '.join(trich.split())
                ngan = ngan if len(ngan) <= 80 else ngan[:77] + '…'
                goi['message']['text'] = f'↪ «{ngan}»\n{phan}'[:TOI_DA_KY_TU]
        ok, loi = post('me/messages', token, goi)
        if not ok:
            return False, loi
    return True, ''


# =====================================================================
# KHÁCH NHẮN DỒN — tách thành từng câu hỏi, trả lời từng câu
# =====================================================================
#
# Chủ dự án 24/09/2026: "khi khách hỏi dồn dập, AI trả lời đủ nhưng sẽ không
# chuẩn cho câu nào chỗ nào". Lô tin gộp trong cửa sổ 4 giây trước đây thành
# MỘT câu hỏi và MỘT câu trả lời trộn.
#
# Nay: lô có từ 2 tin "thật" trở lên thì mô hình chia lô thành NHÓM (tin bị
# tách giữa câu và tin đệm "ok", "bạn ơi" gộp vào nhóm liền kề). Mỗi nhóm đi
# qua lõi như một lượt riêng — tầng 1, nhánh giá, sổ tay đều chạy đúng cho
# từng câu — và câu trả lời trích dẫn tin cuối của nhóm. Chia hỏng thì quay
# về cách cũ: một câu trả lời cho cả lô.

NHOM_TOI_DA = 4
_TU_DEM = frozenset('ok oke okay vang da ban b ad admin shop oi alo hi hello chao uh u uhm thanks '
                    'cam on nhe nha a ah the vay'.split())
_CAU_DAN_NHOM = (
    'Khách nhắn liên tiếp nhiều tin (đánh số). Chia các tin thành NHÓM, mỗi nhóm là MỘT câu hỏi hoặc '
    'MỘT ý cần trả lời riêng. Một câu bị tách ra nhiều tin thì chung một nhóm. Tin chỉ để gọi/đệm '
    '("bạn ơi", "ok", "alo") gộp vào nhóm liền kề. Giữ đúng thứ tự, mỗi tin thuộc đúng một nhóm, '
    f'tối đa {NHOM_TOI_DA} nhóm. CHỈ trả về JSON, không giải thích: {{"nhom": [[1, 2], [3]]}}'
)


def la_tin_dem(van_ban: str) -> bool:
    """Tin chỉ gồm từ gọi/đệm ("Bạn", "ad ơi", "ok b")."""
    try:
        from nao.dinh_tuyen import chuan_hoa
        vb = chuan_hoa(van_ban or '')
    except Exception:  # noqa: BLE001
        vb = (van_ban or '').lower()
    tu = re.sub(r'[^\w\s]', ' ', vb).split()
    return not tu or (len(tu) <= 3 and all(x in _TU_DEM for x in tu))


def nhom_tin(cac_tin: List[str], _goi: Optional[Callable[[str, str], Optional[str]]] = None) -> Optional[List[List[int]]]:
    """Chia lô tin thành nhóm (chỉ số 0-based). None nếu không chia được."""
    if _goi is None:
        from nao.compact import _goi_mo_hinh  # ghi ngân sách như bước tóm gọn
        _goi = lambda h, n: _goi_mo_hinh(h, n, 'vat')  # noqa: E731 - việc vặt: mô hình rẻ
    nguoi_dung = '\n'.join(f'{i}. {t}' for i, t in enumerate(cac_tin, 1))
    try:
        van_ban = _goi(_CAU_DAN_NHOM, nguoi_dung) or ''
        m = re.search(r'\{.*\}', van_ban, re.S)
        nhom = json.loads(m.group(0))['nhom'] if m else None
        nhom = [[int(i) - 1 for i in g] for g in nhom if g]
    except Exception as loi:  # noqa: BLE001
        _log.warning('Không chia được lô tin: %s', loi)
        return None
    phang = [i for g in nhom for i in g]
    if sorted(phang) != list(range(len(cac_tin))) or len(nhom) > NHOM_TOI_DA:
        _log.warning('Mô hình chia lô tin không hợp lệ: %r', nhom)
        return None
    return [sorted(g) for g in sorted(nhom, key=min)]


CAU_CHUYEN_KHOAN = ('Dạ em đã nhận ảnh chuyển khoản của anh/chị. Em chuyển ngay cho người phụ trách đối chiếu, '
                    'xác nhận xong bên em nhắn lại anh/chị tại đây ạ.')


def _chuan_bi_anh_mac_dinh(urls: List[str]) -> Tuple[List[str], List[str]]:
    from kenh.anh import chuan_bi
    return chuan_bi(urls)


def _ghi_anh(lay: Callable[[], Any], id_: int, du: Dict[str, Any]) -> None:
    """Ghi đường dẫn ảnh đã lưu (+ loại, mô tả sau lượt trả lời) vào chat_hang_cho.mo_ta_anh."""
    c = lay()
    if c is None:
        return
    with c:
        with c.cursor() as ct:
            ct.execute('UPDATE chat_hang_cho SET mo_ta_anh = %s WHERE id = %s',
                       (json.dumps(du, ensure_ascii=False)[:4000], int(id_)))
        c.commit()


def _cau_da_bao(nc: Any) -> str:
    from kenh.phieu import CAU_DA_BAO
    ngon_ngu = getattr(nc, 'ngon_ngu', 'vi') if getattr(nc, 'ngon_ngu', 'vi') in CAU_DA_BAO else 'vi'
    try:
        import shop
        zalo = shop.lay(nc.khong_gian).lien_he.get('zalo') or ''
    except Exception:  # noqa: BLE001
        zalo = ''
    return CAU_DA_BAO[ngon_ngu].format(zalo=zalo or 'admin')


# =====================================================================
# GHI ĐOẠN KHÁCH ↔ NHÂN VIÊN VÀO LỊCH SỬ (01/10/2026)
# =====================================================================
# Chủ dự án: "nếu admin và khách trả lời với nhau thì ngữ cảnh khi bot trả lời ko ghi nhận vào toàn
# cảnh". Trước đây lịch sử phiên (chat_phien) chỉ có cặp khách → bot: tin khách bị bỏ qua vì nhân viên
# đang tiếp (bo_qua) và tin nhân viên (loai='nhan_vien') KHÔNG BAO GIỜ vào; bot tiếp quản chỉ được một
# dòng ghi chú gồm ≤4 tin nhân viên trong 60 phút. Nay trước khi bot trả lời, mọi tin như vậy chưa ghi
# (vao_lich_su = 0, trong NGAY_GHI_NV ngày) được gom theo thứ tự thời gian thành các cặp
# (lời khách, "[Nhân viên shop nhắn tay] lời nhân viên") rồi chèn vào lịch sử.

NGAY_GHI_NV = 7
NHAN_NV = '[Nhân viên shop nhắn tay] '


def gom_cap_nhan_vien(dong: List[Dict[str, Any]]) -> List[Tuple[str, str]]:
    """Dòng chat_hang_cho (theo id tăng) -> cặp (khách, nhân viên). Thuần, để tự kiểm."""
    cap: List[Tuple[str, str]] = []
    khach: List[str] = []
    nv: List[str] = []

    def _xa() -> None:
        if khach or nv:
            cap.append(('\n'.join(khach) or '(khách chưa nhắn — nhân viên chủ động nhắn)',
                        NHAN_NV + '\n'.join(nv) if nv else '(chưa ai trả lời các tin này)'))
    for x in dong:
        nd = str(x.get('noi_dung') or '').strip()
        if not nd:
            continue
        if x.get('loai') == 'nhan_vien':
            nv.append(nd)
        else:
            if nv:                                     # khách nhắn lại sau khi nhân viên trả lời -> lượt mới
                _xa()
                khach, nv = [], []
            khach.append(nd)
    _xa()
    return cap


def ghi_nhan_vien_vao_lich_su(lay: Callable[[], Any], kenh_id: int, psid: str, phien: str, khong_gian: str) -> int:
    """Chèn đoạn khách ↔ nhân viên chưa ghi vào lịch sử phiên. Trả số cặp đã chèn."""
    conn = lay()
    if conn is None:
        return 0
    with conn:
        with conn.cursor() as ct:
            ct.execute(f"""SELECT id, loai, noi_dung FROM chat_hang_cho
                           WHERE kenh_id = %s AND psid = %s AND vao_lich_su = 0
                             AND luc >= NOW() - INTERVAL {int(NGAY_GHI_NV)} DAY
                             AND (loai = 'nhan_vien' OR (loai = 'khach' AND trang_thai = 'bo_qua'
                                                        AND noi_dung <> '👍'))
                           ORDER BY id LIMIT 80""", (kenh_id, psid))
            dong = ct.fetchall() or []
        if not dong:
            return 0
        cap = gom_cap_nhan_vien(dong)
        from congcu.phien import chen_luot
        if cap and not chen_luot(phien, cap, khong_gian=khong_gian, may=f'fb{psid}'[:64]):
            return 0                                   # không ghi được: để lần sau thử lại
        ids = [int(x['id']) for x in dong]
        with conn.cursor() as ct:
            ct.execute(f"UPDATE chat_hang_cho SET vao_lich_su = 1 WHERE id IN ({','.join(['%s'] * len(ids))})", ids)
        conn.commit()
    return len(cap)


# =====================================================================
# NHÂN VIÊN GỬI LIKE = TRẢ QUYỀN TƯ VẤN CHO BOT (02/10/2026)
# =====================================================================
# Chủ dự án: "khi tôi gửi like thì bot sẽ tiếp quản lại cuộc tư vấn khách. đôi lúc bot nhận diện sai nhu cầu ... thì
# tôi sẽ sửa lại sau đó gửi like để bot biết và tiếp tục tư vấn". Webhook (public/api/messenger.php) nhận echo LIKE
# của Fanpage: xoá chat_nhuong (like không tính là nhân viên đang tiếp), đưa lại các tin khách đang chờ / bị bỏ qua
# sau tin nhân viên cuối (hạn +2 phút) rồi gọi /kenh/tiep-quan -> tiep_quan():
#   1. ghi đoạn khách ↔ nhân viên vào lịch sử phiên;
#   2. tóm tắt LẠI hồ sơ khách có kèm lời nhân viên (nao/tom_tat: nhân viên đúng hơn bot, nhận định sai cũ bị bỏ);
#   3. cho các tin chờ tới hạn ngay + đánh thức luồng trả lời.

GIO_NV_TIEP_QUAN = 24


def tiep_quan(kenh_id: int, psid: str, *, _lay_ket_noi: Optional[Callable[[], Any]] = None,
              _tom_tat: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
    lay = _lay_ket_noi or csdl.ket_noi
    phien = f'fb{kenh_id}x{psid}'[:64]

    def _doc(ct):
        ct.execute('SELECT khong_gian FROM chat_kenh WHERE id = %s', (kenh_id,))
        kg = str((ct.fetchone() or {}).get('khong_gian') or 'tradingauto')
        ct.execute(f"""SELECT id, loai, noi_dung FROM chat_hang_cho
                       WHERE kenh_id = %s AND psid = %s AND luc >= NOW() - INTERVAL {int(GIO_NV_TIEP_QUAN)} HOUR
                         AND (loai = 'nhan_vien' OR (loai = 'khach' AND noi_dung <> '👍'))
                       ORDER BY id LIMIT 80""", (kenh_id, psid))
        dong = list(ct.fetchall() or [])
        ct.execute('SELECT * FROM chat_phien WHERE phien = %s', (phien,))
        return kg, dong, ct.fetchone()

    kg, dong, dp = csdl.voi_ct(lay, _doc)
    so_cap = ghi_nhan_vien_vao_lich_su(lay, int(kenh_id), str(psid), phien, kg)
    them = [{'cau_hoi': k, 'tra_loi': n} for k, n in gom_cap_nhan_vien(dong) if n.startswith(NHAN_NV)]
    ho_so = None
    if them:
        if _tom_tat is None:
            from nao.tom_tat import xu_ly_phien as _tom_tat  # noqa: N806
        dp = dict(dp or {'phien': phien, 'khong_gian': kg, 'may': f'fb{psid}'[:64], 'nguoi_dung': 'khach',
                         'so_tay': None, 'so_luot': 0, 'tom_tat_luc': None, 'tom_tat': '', 'compact': ''})
        try:
            ho_so = _tom_tat(dp, _lay_ket_noi=_lay_ket_noi, them_luot=them)
        except Exception:  # noqa: BLE001 - hồ sơ không cập nhật được không được chặn bot tiếp quản
            _log.exception('Tiếp quản psid=%s: không tóm tắt lại được hồ sơ', psid)
    csdl.voi_ct(lay, lambda ct: ct.execute(
        "UPDATE chat_hang_cho SET han = NOW() WHERE kenh_id = %s AND psid = %s AND loai = 'khach' AND trang_thai = 'cho'",
        (kenh_id, psid)))
    danh_thuc()
    _log.info('Nhân viên trả quyền cho bot psid=…%s: %d cặp vào lịch sử, %d lượt nhân viên, hồ sơ=%s',
              str(psid)[-4:], so_cap, len(them), ho_so)
    return {'so_cap': so_cap, 'luot_nv': len(them), 'ho_so': ho_so}


# =====================================================================
# XỬ LÝ MỘT KHÁCH
# =====================================================================

def xu_ly_khach(
    kenh_id: int, psid: str,
    *,
    _lay_ket_noi: Optional[Callable[[], Any]] = None,
    _tra_loi: Optional[Callable[[str, Any], Any]] = None,
    _gui: Optional[Callable[..., Tuple[bool, str]]] = None,
    _phieu: Optional[Callable[..., Any]] = None,
    _chuan_bi_anh: Optional[Callable[[List[str]], Tuple[List[str], List[str]]]] = None,
    _nhom: Optional[Callable[[List[str]], Optional[List[List[int]]]]] = None,
    _ghi_nv: Optional[Callable[..., Any]] = None,
) -> str:
    """Trả lời lô tin tới hạn của một khách. Trả mô tả kết quả (để tự kiểm)."""
    lay = _lay_ket_noi or csdl.ket_noi
    if _tra_loi is None:
        from nao.tra_loi import tra_loi as _tra_loi  # noqa: N806 - nhập trễ tránh vòng
    gui_tin = _gui or gui
    if _phieu is None:
        from kenh.phieu import xet_va_mo as _phieu  # noqa: N806 - nhánh xử lý chuyển người

    conn = lay()
    if conn is None:
        return 'khong_csdl'
    with conn:
        with conn.cursor() as ct:
            ct.execute('SELECT * FROM chat_kenh WHERE id = %s', (kenh_id,))
            kenh = ct.fetchone()
            ct.execute("""SELECT id, noi_dung, luc, mid, tep, mo_ta_anh FROM chat_hang_cho
                           WHERE kenh_id = %s AND psid = %s AND loai = 'khach'
                             AND trang_thai = 'cho' AND han <= NOW()
                           ORDER BY id""", (kenh_id, psid))
            lo = ct.fetchall() or []
            if not lo:
                return 'trong'
            ids = [int(x['id']) for x in lo]
            dau_hoi = ','.join(['%s'] * len(ids))
            ct.execute(f"UPDATE chat_hang_cho SET trang_thai = 'dang' WHERE trang_thai = 'cho' AND id IN ({dau_hoi})", ids)
            if ct.rowcount != len(ids):
                # Luồng khác đã nhận một phần lô — trả lại phần mình vừa giành, để lượt sau gom đủ.
                ct.execute(f"UPDATE chat_hang_cho SET trang_thai = 'cho' WHERE trang_thai = 'dang' AND id IN ({dau_hoi})", ids)
                conn.commit()
                return 'tranh_chap'
            ct.execute('SELECT nguoi_tra_loi_luc FROM chat_nhuong WHERE kenh_id = %s AND psid = %s', (kenh_id, psid))
            nh = ct.fetchone()
            ct.execute("""SELECT noi_dung FROM chat_hang_cho
                           WHERE kenh_id = %s AND psid = %s AND loai = 'nhan_vien'
                             AND luc >= NOW() - INTERVAL 60 MINUTE
                           ORDER BY id DESC LIMIT 4""", (kenh_id, psid))
            nhan_vien = [str(x['noi_dung']) for x in reversed(ct.fetchall() or [])]
        conn.commit()

    def _ket(trang_thai: str, ket_qua: str) -> None:
        c2 = lay()
        if c2 is None:
            return
        with c2:
            with c2.cursor() as ct2:
                ct2.execute(f"UPDATE chat_hang_cho SET trang_thai = %s, ket_qua = %s WHERE id IN ({dau_hoi})",
                            [trang_thai, ket_qua[:250]] + ids)
            c2.commit()

    if not kenh or int(kenh.get('bat') or 0) != 1:
        _ket('bo_qua', 'kênh đang tắt')
        return 'kenh_tat'

    # NHƯỜNG NGƯỜI THẬT: nhân viên nhắn tay SAU tin đầu tiên của lô -> người thật đang lo.
    luc_dau = min(x['luc'] for x in lo)
    if nh and nh.get('nguoi_tra_loi_luc') and nh['nguoi_tra_loi_luc'] >= luc_dau:
        _ket('bo_qua', 'nhân viên đã trả lời')
        return 'nhuong'

    # Chỉ bấm like / gửi sticker -> không trả lời (webhook đổi sticker thành "👍").
    if all(str(x['noi_dung']).strip() == '👍' for x in lo):
        _ket('bo_qua', 'khách bấm like / sticker')
        return 'like'

    # Bot sắp trả lời: đưa đoạn khách ↔ nhân viên (nhắn tay) chưa ghi vào lịch sử phiên trước.
    try:
        (_ghi_nv or ghi_nhan_vien_vao_lich_su)(lay, int(kenh_id), str(psid), f'fb{kenh_id}x{psid}'[:64],
                                                str(kenh.get('khong_gian') or 'tradingauto'))
    except Exception:  # noqa: BLE001 - thiếu ngữ cảnh nhân viên không được chặn câu trả lời
        _log.exception('Không ghi được đoạn nhân viên vào lịch sử psid=%s', psid)

    # ẢNH (25/09/2026): tải + lưu (kenh/anh.py) rồi gửi CÙNG lượt trả lời — mô hình
    # hiểu ảnh trong mạch hội thoại, không có lượt "đọc ảnh" tách riêng. Đường dẫn
    # tệp lưu vào mo_ta_anh ngay, để lô bị xử lý lại không phải tải link (có hạn) lần hai.
    anh_lo: Dict[int, Dict[str, Any]] = {}
    for x in lo:
        if not x.get('tep'):
            continue
        try:
            cu = json.loads(x['mo_ta_anh']) if x.get('mo_ta_anh') else {}
        except ValueError:
            cu = {}
        b64: List[str] = []
        tep: List[str] = list(cu.get('tep') or [])
        if tep:
            from kenh.anh import doc_tep
            b64 = [b for b in (doc_tep(t) for t in tep) if b]
        if not b64:
            if _gui is None:
                dang_go(kenh, psid)
            try:
                urls = [u for u in json.loads(x['tep']) if isinstance(u, str)]
                b64, tep = (_chuan_bi_anh or _chuan_bi_anh_mac_dinh)(urls)
            except Exception:  # noqa: BLE001 - ảnh hỏng thì vẫn trả lời phần chữ
                _log.exception('Tải ảnh lỗi psid=%s', psid)
                b64, tep = [], []
            _ghi_anh(lay, int(x['id']), {'tep': tep})
        anh_lo[int(x['id'])] = {'b64': b64, 'tep': tep}

    def _chu_tin(x: Dict[str, Any]) -> str:
        chu = str(x['noi_dung'] or '').strip()
        a = anh_lo.get(int(x['id']))
        if a is None:
            return chu
        chu = '' if chu.startswith('(Khách gửi') else chu
        if a['b64']:
            return chu or f'(Khách gửi {len(a["b64"])} ảnh, không kèm chữ — xem ảnh)'
        return (chu + '\n' if chu else '') + '(Khách gửi ảnh nhưng hệ thống không tải được ảnh)'

    tin = [(x, _chu_tin(x)) for x in lo if _chu_tin(x)]
    if not tin:
        _ket('bo_qua', 'lô rỗng')
        return 'trong'
    nhom: Optional[List[List[int]]] = None
    if sum(1 for _, t in tin if not la_tin_dem(t)) >= 2:
        nhom = (_nhom or nhom_tin)([t for _, t in tin])
    if not nhom or len(nhom) < 2:
        nhom = [list(range(len(tin)))]                 # một câu trả lời cho cả lô, như cũ
    nhieu = len(nhom) > 1

    cac_tang: List[str] = []
    can_nguoi: List[str] = []
    for k, g in enumerate(nhom):
        cau = '\n'.join(tin[i][1] for i in g)
        if len(cau) > TOI_DA_CAU_HOI:
            cau = cau[-TOI_DA_CAU_HOI:]                # giữ phần MỚI nhất
        nc = NguCanh(phien=f'fb{kenh_id}x{psid}'[:64], kenh='messenger', may=f'fb{psid}'[:64],
                     nguoi_dung='khach', khong_gian=str(kenh.get('khong_gian') or 'tradingauto'))
        if nhan_vien and k == 0:
            nc.ghi_chu = ('Nhân viên của shop vừa nhắn khách: «' + ' | '.join(nhan_vien)[-600:] + '». '
                          'Lời nhân viên là ĐÚNG về nhu cầu của khách — nếu khác nhận định trước của em thì theo nhân '
                          'viên. Em tư vấn TIẾP NỐI theo hướng đó: không nói ngược, không lặp lại điều nhân viên đã nói.')
        elif nhieu:
            nc.ghi_chu = (f'Khách nhắn dồn {len(nhom)} câu; câu này là câu {k + 1}, sẽ được TRÍCH DẪN '
                          'riêng. Chỉ trả lời đúng câu này, ngắn gọn; các câu khác trả lời ở tin riêng.')
        xong_go = threading.Event()
        if _gui is None:
            # Hiện "đang soạn tin…" NGAY và giữ nó (Messenger tự tắt sau ~20 giây)
            # — vai chuyên gia (Opus) có thể nghĩ 30–60 giây, không có dấu hiệu
            # này khách tưởng bot bỏ đi.
            dang_go(kenh, psid)

            def _giu_go() -> None:
                while not xong_go.wait(15):
                    dang_go(kenh, psid)
            threading.Thread(target=_giu_go, daemon=True).start()
        id_nhom = [int(tin[i][0]['id']) for i in g]
        nc.anh = [b for i in id_nhom for b in (anh_lo.get(i) or {}).get('b64', [])][:4]
        tep_anh = next((t for i in id_nhom for t in ((anh_lo.get(i) or {}).get('tep') or [])), '')
        try:
            kq = _tra_loi(cau, nc)
        except Exception as loi:  # noqa: BLE001
            _log.exception('Lõi lỗi khi trả lời Messenger psid=%s', psid)
            _ket('loi', f'lõi lỗi: {loi}')
            return 'loi_loi'
        finally:
            xong_go.set()

        cau_trich = cau                                # câu khách nguyên gốc (dòng ↪ «…» nếu Meta từ chối trích dẫn)
        chuyen_khoan = getattr(kq, 'loai_anh', '') == 'chuyen_khoan'
        if chuyen_khoan:
            # Ảnh chuyển khoản: KHÔNG để bot xác nhận tiền (ảnh giả là chiêu lừa quen) — câu
            # cố định + chuyển người, phiếu 'tien' kèm ảnh gốc lên Telegram.
            kq.tra_loi, kq.chuyen_nguoi = CAU_CHUYEN_KHOAN, True
        if nc.anh:
            for i in id_nhom:
                if i in anh_lo:
                    _ghi_anh(lay, i, {'tep': anh_lo[i]['tep'], 'loai': getattr(kq, 'loai_anh', ''),
                                      'mo_ta': getattr(kq, 'mo_ta_anh', '')})
            cau = cau + (f"\n[Ảnh khách gửi{' — ảnh chuyển khoản' if chuyen_khoan else ''}: "
                         f"{getattr(kq, 'mo_ta_anh', '') or 'chưa mô tả'}]")

        if kq.chuyen_nguoi and not chuyen_khoan:
            # Messenger có phiếu + thẻ Telegram: người thật nhắn lại NGAY trong hộp thư,
            # không đẩy khách sang Zalo/Telegram như câu chuyển người chung.
            kq.tra_loi = _cau_da_bao(nc)
        if nhieu:
            ok, loi = gui_tin(kenh, psid, kq.tra_loi,
                              tra_loi_mid=str(tin[g[-1]][0].get('mid') or '') or None, trich=cau_trich)
        else:
            ok, loi = gui_tin(kenh, psid, kq.tra_loi)
        if not ok:
            _ket('loi', f'gửi lỗi: {loi}')
            c3 = lay()
            if c3 is not None:
                with c3:
                    with c3.cursor() as ct3:
                        ct3.execute('UPDATE chat_kenh SET loi_cuoi = %s WHERE id = %s',
                                    (f"{time.strftime('%d/%m %H:%M')} gửi tin lỗi: {loi}"[:490], kenh_id))
                    c3.commit()
            return 'gui_loi'
        cac_tang.append(str(getattr(kq, 'tang', 0)))
        ly_do = None
        try:
            ly_do = _phieu(cau, kq, nc, kenh='messenger', kenh_id=int(kenh_id), khach=str(psid),
                           **({'anh': tep_anh} if tep_anh else {}))
        except Exception:  # noqa: BLE001 - phiếu hỏng không được chặn lượt kế tiếp
            _log.exception('Không mở được phiếu cho psid=%s', psid)
        if ly_do:
            can_nguoi.append(ly_do)

    _ket('xong', ('tầng ' + '+'.join(cac_tang)) + (f' · {len(nhom)} câu trích dẫn' if nhieu else '')
         + (' · phiếu ' + ','.join(can_nguoi) if can_nguoi else ''))
    return 'xong'


# =====================================================================
# LUỒNG NỀN
# =====================================================================

_su_kien = threading.Event()
_da_chay = False
_khoa = threading.Lock()


def danh_thuc() -> None:
    _su_kien.set()


def chay_mot_vong(_lay_ket_noi: Optional[Callable[[], Any]] = None) -> int:
    conn = (_lay_ket_noi or csdl.ket_noi)()
    if conn is None:
        return 0
    try:
        with conn:
            with conn.cursor() as ct:
                ct.execute("""SELECT DISTINCT kenh_id, psid FROM chat_hang_cho
                               WHERE trang_thai = 'cho' AND loai = 'khach' AND han <= NOW()
                               LIMIT 20""")
                viec = ct.fetchall() or []
    except Exception as loi:  # noqa: BLE001
        _log.warning('Không đọc được hàng chờ Messenger: %s', loi)
        return 0
    if not viec:
        return 0
    with concurrent.futures.ThreadPoolExecutor(max_workers=SO_LUONG) as ex:
        list(ex.map(lambda v: xu_ly_khach(int(v['kenh_id']), str(v['psid'])), viec))
    return len(viec)


def khoi_dong_nen() -> None:
    """Bật luồng nền (một lần mỗi tiến trình). CHATBOT_MESSENGER=0 để tắt."""
    global _da_chay
    if os.environ.get('CHATBOT_MESSENGER', '1').strip() == '0':
        return
    with _khoa:
        if _da_chay:
            return
        _da_chay = True
    # Tiến trình trước chết giữa chừng -> tin 'dang' mồ côi. Trả về hàng chờ.
    try:
        conn = csdl.ket_noi()
        if conn is not None:
            with conn:
                with conn.cursor() as ct:
                    ct.execute("UPDATE chat_hang_cho SET trang_thai = 'cho' WHERE trang_thai = 'dang'")
                conn.commit()
    except Exception:  # noqa: BLE001
        pass

    def _vong():
        while True:
            _su_kien.wait(CHU_KY_GIAY)
            _su_kien.clear()
            try:
                chay_mot_vong()
            except Exception:  # noqa: BLE001 - luồng nền không bao giờ được chết
                _log.exception('Vòng Messenger lỗi bất ngờ.')

    threading.Thread(target=_vong, name='kenh-messenger', daemon=True).start()
    _log.info('Đã bật kênh Messenger: quét hàng chờ mỗi %d giây (và ngay khi webhook đánh thức).', CHU_KY_GIAY)


# =====================================================================
# TỰ KIỂM
# =====================================================================
if __name__ == '__main__':
    import datetime as _dt
    logging.disable(logging.WARNING)

    print('=== 1) dinh_dang ===')
    r = dinh_dang('**Bật AutoTrading**\n\n- ý a\n- ý `b`\n### Tiêu đề')
    assert r == 'Bật AutoTrading\n\n• ý a\n• ý b\nTiêu đề', repr(r)
    print('  OK')

    print('\n=== 2) chia_tin ===')
    dai = '\n\n'.join(['x' * 800] * 5)
    ds = chia_tin(dai)
    assert all(len(p) <= TOI_DA_KY_TU for p in ds) and len(ds) >= 3, [len(p) for p in ds]
    assert chia_tin('ngắn') == ['ngắn'] and chia_tin('') == []
    ds = chia_tin('y' * 5000)
    assert all(len(p) <= TOI_DA_KY_TU for p in ds) and ''.join(ds) == 'y' * 5000
    print('  OK', [len(p) for p in chia_tin(dai)])

    print('\n=== 3) gui: typing + metadata bot + chia tin ===')
    da_gui = []
    ok, _ = gui({'page_token': 'T'}, '123', '**a**\n\n' + 'b' * 2500,
                _post=lambda d, t, j: (da_gui.append(j) or (True, '')))
    assert ok and da_gui[0].get('sender_action') == 'typing_on'
    tin = [j for j in da_gui if 'message' in j]
    assert len(tin) >= 2 and all(j['message']['metadata'] == DAU_BOT for j in tin), [len(j.get('message', {}).get('text', '')) for j in da_gui]
    assert all(len(j['message']['text']) <= TOI_DA_KY_TU for j in tin)
    assert tin[0]['message']['text'].startswith('a')
    assert gui({'page_token': ''}, '1', 'x')[0] is False
    print('  OK')

    print('\n=== 4) xu_ly_khach với CSDL giả ===')
    BAY_GIO = _dt.datetime(2026, 9, 24, 10, 0, 0)

    class _CT:
        def __init__(s, db): s.db = db; s._kq = []; s.rowcount = 0
        def __enter__(s): return s
        def __exit__(s, *a): return False
        def execute(s, sql, ts=None):
            s.db['sql'].append((sql, ts)); s._kq = []; s.rowcount = 0
            if 'FROM chat_kenh' in sql:
                s._kq = [s.db['kenh']]
            elif "loai = 'khach'" in sql and sql.strip().startswith('SELECT'):
                s._kq = [r for r in s.db['lo'] if r['trang_thai'] == 'cho']
            elif sql.strip().startswith("UPDATE chat_hang_cho SET trang_thai = 'dang'"):
                n = 0
                for r in s.db['lo']:
                    if r['trang_thai'] == 'cho' and r['id'] in ts:
                        r['trang_thai'] = 'dang'; n += 1
                s.rowcount = n
            elif sql.strip().startswith('UPDATE chat_hang_cho SET trang_thai = %s'):
                for r in s.db['lo']:
                    if r['id'] in ts[2:]:
                        r['trang_thai'] = ts[0]; r['ket_qua'] = ts[1]
            elif 'FROM chat_nhuong' in sql:
                s._kq = [s.db['nhuong']] if s.db['nhuong'] else []
            elif "loai = 'nhan_vien'" in sql:
                s._kq = [{'noi_dung': x} for x in s.db['nhan_vien']]
        def fetchone(s): return s._kq[0] if s._kq else None
        def fetchall(s): return list(s._kq)

    class _Conn:
        def __init__(s, db): s.db = db
        def __enter__(s): return s
        def __exit__(s, *a): return False
        def cursor(s): return _CT(s.db)
        def commit(s): pass

    class _KQ:
        def __init__(s, t, cn=False): s.tra_loi = t; s.chuyen_nguoi = cn; s.tang = 2

    def _db(nhuong=None, nhan_vien=()):
        return {'sql': [], 'kenh': {'id': 1, 'bat': 1, 'khong_gian': 'tradingauto', 'page_token': 'T', 'ten': 'Page'},
                'lo': [{'id': 10, 'noi_dung': 'cài copytrade', 'luc': BAY_GIO, 'trang_thai': 'cho'},
                       {'id': 11, 'noi_dung': 'trên mt4', 'luc': BAY_GIO, 'trang_thai': 'cho'}],
                'nhuong': nhuong, 'nhan_vien': list(nhan_vien)}

    db = _db()
    hoi, gui_ra, bao_ra = {}, [], []
    kq = xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db),
                     _tra_loi=lambda c, n: (hoi.update(cau=c, nc=n) or _KQ('**Dạ** cài thế này')),
                     _gui=lambda k, p, t: (gui_ra.append((p, t)) or (True, '')), _phieu=lambda *a, **k: None,
                     _nhom=lambda t: [[0, 1]])
    assert kq == 'xong' and hoi['cau'] == 'cài copytrade\ntrên mt4', hoi
    assert hoi['nc'].kenh == 'messenger' and hoi['nc'].may == 'fb999' and hoi['nc'].phien.startswith('fb1x999')
    assert gui_ra == [('999', '**Dạ** cài thế này')] and all(r['trang_thai'] == 'xong' for r in db['lo'])
    print('  OK — gộp 2 tin, gọi lõi kênh messenger, gửi, đánh dấu xong')

    db = _db(nhuong={'nguoi_tra_loi_luc': BAY_GIO + _dt.timedelta(minutes=2)})
    gui_ra.clear()
    kq = xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db), _tra_loi=lambda c, n: _KQ('x'),
                     _gui=lambda k, p, t: (gui_ra.append(1) or (True, '')), _phieu=lambda *a, **k: None)
    assert kq == 'nhuong' and not gui_ra and all(r['trang_thai'] == 'bo_qua' for r in db['lo'])
    print('  OK — nhân viên trả lời SAU tin khách -> bot nhường, không gửi gì')

    db = _db(nhuong={'nguoi_tra_loi_luc': BAY_GIO - _dt.timedelta(minutes=10)}, nhan_vien=['Dạ anh cài bản MT4 nhé'])
    hoi.clear()
    kq = xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db),
                     _tra_loi=lambda c, n: (hoi.update(nc=n) or _KQ('tiếp', cn=True)),
                     _gui=lambda k, p, t: (gui_ra.append(t) or (True, '')), _phieu=lambda c, kq, n, **k: (bao_ra.append((c, k)) or 'chua_co'),
                     _nhom=lambda t: None)
    assert kq == 'xong' and 'Dạ anh cài bản MT4 nhé' in hoi['nc'].ghi_chu, hoi
    assert bao_ra and bao_ra[-1][1] == {'kenh': 'messenger', 'kenh_id': 1, 'khach': '999'}, bao_ra
    assert 'người phụ trách' in gui_ra[-1] and any('phiếu chua_co' in str(r.get('ket_qua')) for r in db['lo'])
    print('  OK — hết hạn chờ, nhân viên không trả lời -> bot tiếp quản, biết nhân viên đã nói gì; chuyển người -> mở phiếu, dặn khách chờ tại đây')

    db = _db(); db['kenh']['bat'] = 0
    assert xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db), _tra_loi=lambda c, n: _KQ('x'),
                       _gui=lambda *a: (True, ''), _phieu=lambda *a, **k: None) == 'kenh_tat'
    print('  OK — kênh tắt -> bỏ qua')

    print('\n=== 5) khách nhắn dồn: chia nhóm, trả lời trích dẫn từng câu ===')
    assert la_tin_dem('Bạn') and la_tin_dem('ad ơi') and la_tin_dem('ok b') and not la_tin_dem('giá bao nhiêu')
    assert nhom_tin(['a', 'b', 'c'], _goi=lambda h, n: '```json\n{"nhom": [[3], [1, 2]]}\n```') == [[0, 1], [2]]
    assert nhom_tin(['a', 'b'], _goi=lambda h, n: '{"nhom": [[1]]}') is None          # thiếu tin 2
    assert nhom_tin(['a', 'b'], _goi=lambda h, n: 'không phải json') is None
    db = _db()
    db['lo'] = [{'id': 20, 'mid': 'm20', 'noi_dung': 'dca giá bao nhiêu', 'luc': BAY_GIO, 'trang_thai': 'cho'},
                {'id': 21, 'mid': 'm21', 'noi_dung': 'Bạn', 'luc': BAY_GIO, 'trang_thai': 'cho'},
                {'id': 22, 'mid': 'm22', 'noi_dung': 'cài mt4 được không', 'luc': BAY_GIO, 'trang_thai': 'cho'}]
    hoi_ds, gui_ra = [], []
    kq = xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db),
                     _tra_loi=lambda c, n: (hoi_ds.append((c, n.ghi_chu)) or _KQ('đáp: ' + c.split('\n')[0])),
                     _gui=lambda k, p, t, **kw: (gui_ra.append((t, kw)) or (True, '')), _phieu=lambda *a, **k: None,
                     _nhom=lambda t: [[0, 1], [2]])
    assert kq == 'xong' and [c for c, _ in hoi_ds] == ['dca giá bao nhiêu\nBạn', 'cài mt4 được không'], hoi_ds
    assert 'câu 2' in hoi_ds[1][1]
    assert [kw['tra_loi_mid'] for _, kw in gui_ra] == ['m21', 'm22'], gui_ra
    assert all(r['trang_thai'] == 'xong' and '2 câu trích dẫn' in r['ket_qua'] for r in db['lo'])
    print('  OK — 2 nhóm -> 2 lượt lõi, mỗi câu trả lời trích dẫn tin cuối của nhóm')

    goi_ra = []
    def _post_tu_choi(d, t, j):
        goi_ra.append(j)
        return (False, 'HTTP 400') if 'reply_to' in j else (True, '')
    ok, _ = gui({'page_token': 'T'}, '1', 'trả lời', _post=_post_tu_choi, tra_loi_mid='m1', trich='giá dca bao nhiêu')
    tin = [j for j in goi_ra if 'message' in j]
    assert ok and tin[0]['reply_to'] == {'mid': 'm1'} and tin[1]['message']['text'].startswith('↪ «giá dca bao nhiêu»')
    print('  OK — Meta từ chối trích dẫn -> gửi lại kèm dòng ↪ «câu khách»')
    print('\n=== 6) ảnh: đi CÙNG lượt trả lời; chuyển khoản -> câu cố định + phiếu kèm ảnh; like -> bỏ qua ===')

    class _KQA(_KQ):
        def __init__(s, t, loai='', mo_ta=''):
            super().__init__(t); s.loai_anh = loai; s.mo_ta_anh = mo_ta

    db = _db()
    db['lo'] = [{'id': 30, 'mid': 'm30', 'noi_dung': '(Khách gửi 1 ảnh)', 'luc': BAY_GIO, 'trang_thai': 'cho',
                 'tep': '["https://fb/a.jpg"]', 'mo_ta_anh': None}]
    hoi, tai = {}, []
    kq = xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db),
                     _tra_loi=lambda c, n: (hoi.update(cau=c, anh=list(n.anh)) or _KQA('Lỗi 10027 là do…', 'loi', 'EA báo 10027')),
                     _gui=lambda k, p, t: (True, ''), _phieu=lambda *a, **k: None, _nhom=lambda t: None,
                     _chuan_bi_anh=lambda u: (tai.append(u) or (['QkFTRTY0'], ['/x.jpg'])))
    assert kq == 'xong' and hoi['anh'] == ['QkFTRTY0'], 'ảnh phải đi cùng lượt gọi lõi'
    assert hoi['cau'].startswith('(Khách gửi 1 ảnh') and tai == [['https://fb/a.jpg']], hoi
    luu = [ts for s, ts in db['sql'] if s.startswith('UPDATE chat_hang_cho SET mo_ta_anh')]
    assert len(luu) == 2 and 'EA báo 10027' in luu[-1][0], 'lưu đường dẫn trước, loại + mô tả sau lượt trả lời'

    db = _db()
    db['lo'] = [{'id': 31, 'mid': 'm31', 'noi_dung': 'mình chuyển rồi nhé', 'luc': BAY_GIO, 'trang_thai': 'cho',
                 'tep': '["https://fb/ck.jpg"]', 'mo_ta_anh': None}]
    gui_ra, phieu_ra = [], []
    kq = xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db),
                     _tra_loi=lambda c, n: _KQA('Dạ em đã nhận được tiền ạ', 'chuyen_khoan', 'biên lai 500.000đ'),
                     _gui=lambda k, p, t: (gui_ra.append(t) or (True, '')), _nhom=lambda t: None,
                     _phieu=lambda c, kq_, n, **k: (phieu_ra.append((c, kq_.chuyen_nguoi, k.get('anh'))) or 'tien'),
                     _chuan_bi_anh=lambda u: (['QQ=='], ['/ck.jpg']))
    assert kq == 'xong' and gui_ra == [CAU_CHUYEN_KHOAN], 'không bao giờ gửi câu mô hình tự xác nhận tiền'
    assert phieu_ra and phieu_ra[0][1] is True and phieu_ra[0][2] == '/ck.jpg'
    assert 'mình chuyển rồi nhé' in phieu_ra[0][0] and 'ảnh chuyển khoản' in phieu_ra[0][0]

    db = _db()
    db['lo'] = [{'id': 32, 'mid': 'm32', 'noi_dung': '👍', 'luc': BAY_GIO, 'trang_thai': 'cho', 'tep': None, 'mo_ta_anh': None}]
    assert xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db), _tra_loi=lambda c, n: _KQ('x'),
                       _gui=lambda *a: (True, ''), _phieu=lambda *a, **k: None) == 'like'
    print('  OK')

    print('[] Gom đoạn khách ↔ nhân viên thành các cặp lịch sử (01/10/2026)...')
    g = gom_cap_nhan_vien([
        {'loai': 'khach', 'noi_dung': 'giá copytrade sao'},
        {'loai': 'khach', 'noi_dung': 'có dùng thử ko'},
        {'loai': 'nhan_vien', 'noi_dung': '10 credit/tháng ạ'},
        {'loai': 'nhan_vien', 'noi_dung': 'dùng thử 7 ngày'},
        {'loai': 'khach', 'noi_dung': 'ok để mình thử'},
        {'loai': 'nhan_vien', 'noi_dung': 'em gửi key nhé'},
    ])
    assert g == [('giá copytrade sao\ncó dùng thử ko', NHAN_NV + '10 credit/tháng ạ\ndùng thử 7 ngày'),
                 ('ok để mình thử', NHAN_NV + 'em gửi key nhé')], g
    g = gom_cap_nhan_vien([{'loai': 'nhan_vien', 'noi_dung': 'chào anh'}, {'loai': 'khach', 'noi_dung': 'hi'}])
    assert g[0] == ('(khách chưa nhắn — nhân viên chủ động nhắn)', NHAN_NV + 'chào anh'), g
    assert g[1] == ('hi', '(chưa ai trả lời các tin này)'), g
    da_goi = []
    db = _db()
    db['lo'] = [{'id': 40, 'mid': 'm40', 'noi_dung': 'còn đó không', 'luc': BAY_GIO, 'trang_thai': 'cho', 'tep': None, 'mo_ta_anh': None}]
    assert xu_ly_khach(1, '999', _lay_ket_noi=lambda: _Conn(db), _tra_loi=lambda c, n: _KQ('x'),
                       _gui=lambda *a, **k: (True, ''), _phieu=lambda *a, **k: None, _nhom=lambda t: None,
                       _ghi_nv=lambda *a: da_goi.append(a[3])) == 'xong'
    assert da_goi == ['fb1x999'], 'bot trả lời thì phải ghi đoạn nhân viên vào ĐÚNG phiên trước'
    print('  OK')
    print('\n=== 8) nhân viên gửi like: ghi lịch sử, tóm tắt lại hồ sơ CÓ lời nhân viên, tin chờ tới hạn ngay ===')

    class _CtTq:
        def __init__(s, sql): s.sql, s._kq, s.rowcount = sql, [], 1
        def __enter__(s): return s
        def __exit__(s, *a): return False
        def execute(s, q, ts=()):
            s.sql.append(' '.join(q.split()))
            if 'FROM chat_kenh' in q:
                s._kq = [{'khong_gian': 'tradingauto'}]
            elif 'FROM chat_hang_cho' in q:
                s._kq = [{'id': 1, 'loai': 'khach', 'noi_dung': 'mình muốn thuê bot'},
                         {'id': 2, 'loai': 'nhan_vien', 'noi_dung': 'Dạ ý anh là code bot theo chiến lược riêng ạ'}]
            elif 'FROM chat_phien' in q:
                s._kq = [{'phien': 'fb2x9', 'khong_gian': 'tradingauto', 'may': 'fb9', 'nguoi_dung': 'khach'}]
            else:
                s._kq = []
        def fetchone(s): return s._kq[0] if s._kq else None
        def fetchall(s): return s._kq

    class _ConnTq:
        def __init__(s, sql): s.sql = sql
        def __enter__(s): return s
        def __exit__(s, *a): return False
        def cursor(s): return _CtTq(s.sql)
        def commit(s): pass

    sql_tq, goi_tt = [], []
    import kenh.messenger as _m
    _goc_ghi, _goc_dt = _m.ghi_nhan_vien_vao_lich_su, _m.danh_thuc
    _m.ghi_nhan_vien_vao_lich_su = lambda *a, **k: 1
    _m.danh_thuc = lambda: sql_tq.append('DANH_THUC')
    try:
        kq = _m.tiep_quan(2, '9', _lay_ket_noi=lambda: _ConnTq(sql_tq),
                          _tom_tat=lambda dp, **k: goi_tt.append((dp['phien'], k['them_luot'])) or 'ai')
    finally:
        _m.ghi_nhan_vien_vao_lich_su, _m.danh_thuc = _goc_ghi, _goc_dt
    assert kq == {'so_cap': 1, 'luot_nv': 1, 'ho_so': 'ai'}, kq
    assert goi_tt == [('fb2x9', [{'cau_hoi': 'mình muốn thuê bot',
                                  'tra_loi': NHAN_NV + 'Dạ ý anh là code bot theo chiến lược riêng ạ'}])], goi_tt
    assert any(q.startswith('UPDATE chat_hang_cho SET han = NOW()') for q in sql_tq) and sql_tq[-1] == 'DANH_THUC'
    print('  OK')

    print('\nTỰ KIỂM kenh/messenger.py QUA.')
