main
John Lauer v1.3.13 (insiders): Shared-mailbox protection for the Grok webhook: 6 fires a minute per PC and 3 per thread, a shared… 2d046db 5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
"""Chat verbs: grok_chat_status / grok_chat_read via UIA on the Grok Bot hwnd.

Never steal focus. Never return webhook keys. UIA first, not OCR. Never CDP 9222.
Uses grok_hwnd / _cb / _callback_ok from handlers.windows after the hwnd unwrap fix.
"""

from __future__ import annotations

import re
import time

from grok_detect import HOST_APP, detect_host
from handlers.windows import (
    RESTART_HINT,
    _cb,
    _callback_ok,
    _title_of,
    flatten_callback,
    grok_hwnd,
    host_launch_flags_ok,
    main_window_state,
    read_target_hwnd,
)

# Proven UIA on Grok Bot.exe hwnd (AdomLapper): Document named "Grok Bot",
# Edit "Prompt", heading / sand-conversation-heading ("New Bot"),
# Text/button "<agent> is working" while thinking.
DOCUMENT_NAME = "Grok Bot"
PROMPT_NAME = "Prompt"
HEADING_AID = "sand-conversation-heading"
VIEW_DETAILS = "View conversation details"

SIDEBAR_NOISE = {
    "bot list",
    "new bot or channel",
    "search",
    "prompt",
    "view conversation details",
    "conversation",
    "grok bot",
}

_WORKING_SUFFIXES = (" is working", " is thinking")

# Title-bar caption buttons. With no thread list on the page (blank window after a
# background launch, or the reconnect page) a Heading query can land on these, and
# focusedThread once read "Minimize" (wiki #5). Never report one as a thread.
CAPTION_BUTTONS = {
    "minimize", "maximize", "restore", "restore down", "close", "system",
    "system menu", "application",
}

# The page Grok Bot shows when the agent box (Grok Bot's computer) is down:
# "Reconnecting to your computer... Couldn't Reach Grok Bot's Computer ...
# Recover Grok Bot's Computer / Retry". Matched case-insensitively, both apostrophes.
_UNREACHABLE_MARKERS = (
    "couldn't reach", "couldn\u2019t reach",
    "recover grok bot's computer", "recover grok bot\u2019s computer",
)
_RECONNECT_MARKERS = ("reconnecting to your computer",)

# Per-callback UIA budget. A blank window used to run grok_chat_read into ab's 60 s
# verb timeout; every chat callback now gives up after this, and the Document read
# has an overall budget too.
UI_TIMEOUT = 8
DOC_READ_BUDGET = 15.0


def _ui(command: str, args: dict) -> dict:
    return _cb(command, args, timeout=UI_TIMEOUT)

# Never return webhook keys / bearer tokens in transcript.
_SECRET_RE = re.compile(
    r"(?:crsr_[A-Za-z0-9_-]+)"
    r"|(?:Bearer\s+[A-Za-z0-9._\-+=/]+)"
    r"|(?:Webhook\s+key\s*[:=]\s*\S+)",
    re.IGNORECASE,
)


def _as_controls(blob) -> list[dict]:
    merged = flatten_callback(blob)
    if isinstance(merged, list):
        return [c for c in merged if isinstance(c, dict)]
    if not isinstance(merged, dict):
        return []
    out: list[dict] = []
    for key in ("controls", "matches", "items", "result", "nodes", "windows"):
        val = merged.get(key)
        if isinstance(val, list):
            out.extend(c for c in val if isinstance(c, dict))
    data = merged.get("data")
    if isinstance(data, list):
        out.extend(c for c in data if isinstance(c, dict))
    best = merged.get("best")
    if isinstance(best, dict):
        out.append(best)
    if not out and (merged.get("name") or merged.get("hwnd") or merged.get("role")):
        out.append(merged)
    return out


def _ctrl_name(c: dict) -> str:
    return str(c.get("name") or c.get("Name") or c.get("title") or "")


def _ctrl_value(c: dict) -> str:
    for k in ("value", "Value", "currentValue", "text", "content"):
        v = c.get(k)
        if v is not None and str(v).strip():
            return str(v).strip()
    return ""


def _extract_text(blob) -> str:
    merged = flatten_callback(blob)
    if isinstance(merged, str):
        return merged
    if isinstance(merged, list):
        parts = []
        for item in merged:
            if isinstance(item, str) and item.strip():
                parts.append(item)
            elif isinstance(item, dict):
                t = _extract_text(item)
                if t:
                    parts.append(t)
        return "\n".join(parts)
    if not isinstance(merged, dict):
        return ""
    for k in ("text", "value", "content", "all", "visible"):
        v = merged.get(k)
        if isinstance(v, str) and v.strip():
            return v
    data = merged.get("data")
    if isinstance(data, str) and data.strip():
        return data
    if isinstance(data, dict):
        for k in ("text", "value", "content"):
            v = data.get(k)
            if isinstance(v, str) and v.strip():
                return v
    best = merged.get("best")
    if isinstance(best, dict):
        t = _extract_text(best)
        if t:
            return t
    return ""


def _looks_working(name: str) -> bool:
    nl = (name or "").strip().lower()
    if not nl:
        return False
    if nl.endswith(" is working") or nl.endswith(" is thinking"):
        return True
    if nl in ("working", "thinking"):
        return True
    return False


def _strip_working(name: str) -> str:
    n = (name or "").strip()
    nl = n.lower()
    for suf in _WORKING_SUFFIXES:
        if nl.endswith(suf):
            return n[: len(n) - len(suf)].strip()
    return ""


def _redact_secrets(text: str) -> str:
    if not text:
        return text
    return _SECRET_RE.sub("[redacted]", text)


def _window_missing(verb: str) -> dict:
    info = detect_host()
    thinking = False
    ms = main_window_state()
    if ms["mainWindowHidden"]:
        out = {"success": False, "errorCode": "main_window_hidden", "thinking": False, "idle": False,
               "agentName": None, "hwnd": None, "title": None, "hostApp": HOST_APP,
               "windowEtiquette": "background",
               "_hint": "Grok Bot's main window is hidden (only small secondary windows are visible). "
                        "Its hwnd is not remembered on this PC, so it cannot be read in place. "
                        "grok_open {\"restart\":true,\"reason\":\"...\"} relaunches it in the background.",
               "_next": "grok_open {\"restart\":true,\"reason\":\"...\"}"}
        out.update(ms)
        return out
    return {
        "success": False,
        "errorCode": "window_not_found",
        "error": "No Grok Bot window.",
        "thinking": thinking,
        "idle": False,
        "agentName": None,
        "hwnd": None,
        "title": None,
        "hostApp": HOST_APP,
        "hostAppFallback": info.get("hostAppFallback"),
        "windowEtiquette": "background",
        "_hint": (
            "No Grok Bot hwnd (desktop_find_window titleContains exact \"Grok Bot\"). "
            f"Call grok_open {{}} then grok_{verb} {{}}. Never steal focus. Never kill Cursor. "
            "Do not treat Google Chrome / ai-grok-bot as the host."
        ),
        "_next": f"grok_open {{}} then grok_{verb} {{}}",
    }


def _find_working(hwnd: int) -> tuple[bool, str]:
    """Return (thinking, working_control_name). Read-only; never focuses."""
    queries = (
        {"hwnd": hwnd, "contains": "is working"},
        {"hwnd": hwnd, "nameContains": "is working"},
        {"hwnd": hwnd, "contains": "working"},
    )
    for args in queries:
        blob = _ui("desktop_find_controls", args)
        ctrls = _as_controls(blob)
        if not ctrls:
            ctrls = _as_controls(_ui("desktop_find_control", args))
        for c in ctrls:
            n = _ctrl_name(c) or _ctrl_value(c)
            if _looks_working(n):
                return True, n
    return False, ""


def _is_thread_name(n: str) -> bool:
    nl = (n or "").strip().lower()
    return bool(nl) and nl not in SIDEBAR_NOISE and nl not in CAPTION_BUTTONS


def _read_agent_name(hwnd: int, working_name: str = "") -> str | None:
    """Focused thread name, or None when the page shows no thread (never a caption button)."""
    stripped = _strip_working(working_name)
    if stripped:
        return stripped
    queries = (
        {"hwnd": hwnd, "automationId": HEADING_AID},
        {"hwnd": hwnd, "name": HEADING_AID},
        {"hwnd": hwnd, "role": "Heading"},
        {"hwnd": hwnd, "name": VIEW_DETAILS},
    )
    for args in queries:
        blob = _ui("desktop_find_control", args)
        for c in _as_controls(blob):
            n = (_ctrl_name(c) or _ctrl_value(c)).strip()
            if _is_thread_name(n):
                return n
        blob2 = _ui("desktop_find_controls", args)
        for c in _as_controls(blob2):
            n = (_ctrl_name(c) or _ctrl_value(c)).strip()
            if _is_thread_name(n):
                return n
    return None


def _prompt_value(hwnd: int) -> str:
    blob = _ui("desktop_ui_get", {"hwnd": hwnd, "name": PROMPT_NAME, "role": "Edit"})
    text = _extract_text(blob)
    if text:
        return text.strip()
    blob = _ui("desktop_find_control", {"hwnd": hwnd, "name": PROMPT_NAME, "role": "Edit"})
    for c in _as_controls(blob):
        v = _ctrl_value(c)
        if v:
            return v
    return ""


def _strip_prompt_garbage(text: str, prompt_val: str) -> str:
    """Drop the Prompt compose box if it is tacked onto the Document transcript."""
    out = text or ""
    pv = (prompt_val or "").strip()
    if pv and out.rstrip().endswith(pv):
        out = out.rstrip()[: -len(pv)].rstrip()
    # Trailing isolated "Prompt" label (compose chrome, not a message).
    lines = out.splitlines()
    while lines and lines[-1].strip().lower() in ("prompt", ""):
        if lines[-1].strip() == "":
            lines.pop()
            continue
        if lines[-1].strip().lower() == "prompt":
            lines.pop()
            continue
        break
    return "\n".join(lines).rstrip()


def _read_document_text(hwnd: int) -> tuple[str, dict | None]:
    """desktop_ui_text {hwnd, role:Document, range:all} — proven Document 'Grok Bot'."""
    last = None
    deadline = time.monotonic() + DOC_READ_BUDGET
    for args in (
        {"hwnd": hwnd, "role": "Document", "range": "all"},
        {"hwnd": hwnd, "name": DOCUMENT_NAME, "role": "Document", "range": "all"},
        {"hwnd": hwnd, "name": DOCUMENT_NAME, "range": "all"},
    ):
        if time.monotonic() > deadline:
            break
        last = _ui("desktop_ui_text", args)
        if not _callback_ok(last):
            continue
        text = _extract_text(last)
        if text.strip():
            return text, last
    return "", last


def computer_state_from_text(text: str) -> tuple[str, str | None]:
    """(state, bannerLine) from the page text. unreachable | reconnecting | no_banner | unknown.

    no_banner is NOT proof the box is up: the banner only shows on some pages.
    """
    if not (text or "").strip():
        return "unknown", None
    for line in text.splitlines():
        ll = line.strip().lower()
        if any(m in ll for m in _UNREACHABLE_MARKERS):
            return "unreachable", line.strip()[:200]
    for line in text.splitlines():
        ll = line.strip().lower()
        if any(m in ll for m in _RECONNECT_MARKERS):
            return "reconnecting", line.strip()[:200]
    return "no_banner", None


def computer_fields(state: str, banner: str | None) -> dict:
    return {
        "computerState": state,
        "computerReachable": False if state in ("unreachable", "reconnecting") else None,
        "computerBanner": banner,
    }


def page_probe(hwnd: int) -> dict:
    """One bounded Document read: is there a page yet, and is Grok Bot's computer down?"""
    raw, last = _read_document_text(hwnd)
    state, banner = computer_state_from_text(raw)
    return {"hasDocument": bool((raw or "").strip()), "text": raw or "", "last": last,
            "state": state, "banner": banner}


_PROBE_CACHE: dict = {}
PROBE_TTL = 30.0


def probe_computer(max_age: float = PROBE_TTL) -> dict:
    """computer_* fields for readiness / webhook verbs. Read-only, bounded, never raises.

    Cached for 30 s: Hydrogen polls grok_readiness thousands of times a day (16k on crr), and
    each uncached probe is several UIA callbacks on the user's PC."""
    hit = _PROBE_CACHE.get("v")
    if hit and time.monotonic() - _PROBE_CACHE.get("t", 0) < max_age:
        return dict(hit, computerStateAgeSeconds=int(time.monotonic() - _PROBE_CACHE["t"]))
    out = _probe_computer_uncached()
    _PROBE_CACHE.update(v=out, t=time.monotonic())
    return dict(out)


def _probe_computer_uncached() -> dict:
    try:
        hwnd, _win = grok_hwnd(allow_cursor=False)
        if not hwnd:
            out = computer_fields("unknown", None)
            out["computerStateReason"] = "no Grok Bot window to read"
            return out
        pr = page_probe(hwnd)
        out = computer_fields(pr["state"], pr["banner"])
        if not pr["hasDocument"]:
            out["computerStateReason"] = "Grok Bot window has no page yet (blank or loading)"
        return out
    except Exception as e:  # never let a probe break the verb it decorates
        out = computer_fields("unknown", None)
        out["computerStateReason"] = f"probe failed: {e.__class__.__name__}"
        return out


UNREACHABLE_HINT = (
    "Grok Bot shows \"Couldn't Reach Grok Bot's Computer\": the agent box is down, so webhook "
    "events are accepted (HTTP 200) but no reply can come back. Ask the user to press "
    "Recover Grok Bot's Computer / Retry in Grok Bot, then re-check grok_chat_status."
)
NO_PAGE_REASON = "no page exposed to UIA (window blank or still loading)"


def _no_page(hwnd: int, win, probe: dict, verb: str) -> dict:
    """Fast answer when the window has no Document, or shows the unreachable page."""
    state = probe["state"]
    down = state in ("unreachable", "reconnecting")
    flags_ok = None if down else host_launch_flags_ok()
    out = {
        "success": False,
        "errorCode": "computer_unreachable" if down else "no_transcript",
        "thinking": False,
        "idle": True,
        "agentName": None,
        "agent": None,
        "focusedThread": None,
        "focusedThreadReason": ("Grok Bot is showing the reconnect page, not a thread" if down
                                else NO_PAGE_REASON),
        "text": "",
        "length": 0,
        "visibleText": _redact_secrets(probe["text"])[:1000],
        "hwnd": hwnd,
        "title": _title_of(win),
        "windowEtiquette": "background",
        "_hint": (UNREACHABLE_HINT if down else
                  "The Grok Bot window shows no page to UIA (only caption buttons). It may still be "
                  "loading: retry in 30 s, or grok_screenshot to see it. Answered fast instead of "
                  "waiting out the verb timeout."),
        "_next": ("grok_chat_status {} after the user presses Retry" if down
                  else f"wait 30 s, then grok_{verb} {{}}"),
    }
    out.update(computer_fields(state, probe["banner"]))
    out["launchedWithAccessibility"] = flags_ok
    if flags_ok is False:
        out["focusedThreadReason"] = "Grok Bot's page is not exposed to UIA (started without the accessibility switches)"
        out["_hint"] = RESTART_HINT
        out["_next"] = "grok_open {\"restart\":true,\"reason\":\"Grok Bot page invisible to UIA\"}"
    return out


def _select_sidebar_thread(hwnd: int, name: str) -> dict:
    """Best-effort click a sidebar row by UIA name. Does not steal OS focus beyond Invoke."""
    last = None
    for args in (
        {"hwnd": hwnd, "name": name},
        {"hwnd": hwnd, "name": name, "role": "ListItem"},
        {"hwnd": hwnd, "name": name, "role": "Button"},
        {"hwnd": hwnd, "nameContains": name},
    ):
        last = _ui("desktop_ui_click", args)
        if _callback_ok(last) and not (isinstance(last, dict) and last.get("error")):
            return last
    return last if isinstance(last, dict) else {"success": False}


def handle_chat_status(_args: dict) -> dict:

    """thinking=true when UIA exposes '<agent> is working' / working chip. Never steals focus."""
    hwnd, win, hidden = read_target_hwnd(allow_cursor=False)
    if not hwnd:
        hwnd, win, hidden = read_target_hwnd(allow_cursor=True)
    if not hwnd:
        return _window_missing("chat_status")

    probe = page_probe(hwnd)
    if not probe["hasDocument"] or probe["state"] in ("unreachable", "reconnecting"):
        out = _no_page(hwnd, win, probe, "chat_status")
        # status is an observation, not a failed read: the window exists
        out["success"] = True
        out.pop("text", None); out.pop("length", None)
        return out

    thinking, working_name = _find_working(hwnd)
    agent = _read_agent_name(hwnd, working_name)
    title = _title_of(win) or HOST_APP
    out = {
        "success": True,
        "thinking": bool(thinking),
        "idle": (not thinking),
        "agentName": agent,
        "agent": agent,
        "focusedThread": agent,
        "hwnd": hwnd,
        "title": title,
        "windowEtiquette": "background",
        "_hint": (
            f"Grok Bot hwnd={hwnd} focusedThread={agent!r} "
            + ("thinking (UIA name like '<agent> is working')." if thinking else "idle (no working chip).")
            + " Read-only UIA — focus not stolen. grok_chat_read for the visible transcript."
        ),
    }
    if agent is None:
        out["focusedThreadReason"] = ("main window is hidden: the heading and the working chip are not "
                                      "readable until it is shown" if hidden else "no conversation heading on the page")
    out["mainWindowHidden"] = hidden
    if hidden:
        out["thinking"] = None; out["idle"] = None
    out.update(computer_fields(probe["state"], probe["banner"]))
    return out


def handle_chat_read(args: dict) -> dict:
    """Visible transcript via desktop_ui_text Document range=all.

    limit is CHARACTERS from the END (not messages). Values under 500 are coerced
    to max(limit*250, 4000). Pass 4000+ to search for a nonce.

    Optional args.thread / args.agent: try to click that sidebar row then re-read.
    Always returns focusedThread (heading / agent name). After cold launch the focused
    thread may not be grok-bridge — callers should pass thread/agent or wait after grok_open.
    """
    hwnd, win, hidden = read_target_hwnd(allow_cursor=False)
    if not hwnd:
        hwnd, win, hidden = read_target_hwnd(allow_cursor=True)
    if not hwnd:
        miss = _window_missing("chat_read")
        miss["text"] = ""
        miss["length"] = 0
        miss["focusedThread"] = None
        miss["agent"] = None
        return miss

    probe = page_probe(hwnd)
    if not probe["hasDocument"] or probe["state"] in ("unreachable", "reconnecting"):
        return _no_page(hwnd, win, probe, "chat_read")

    wanted = str(args.get("thread") or args.get("agent") or "").strip()
    select_attempt = None
    if wanted and not hidden:
        select_attempt = _select_sidebar_thread(hwnd, wanted)

    thinking, working_name = _find_working(hwnd)
    agent = _read_agent_name(hwnd, working_name)
    if select_attempt is None:
        raw, last = probe["text"], probe["last"]    # nothing clicked: the probe read is current
    else:
        raw, last = _read_document_text(hwnd)

    # Cold start: focused thread is often not grok-bridge. If nothing requested and
    # transcript is empty/tiny, try the grok-bridge sidebar row once.
    tiny = len((raw or "").strip()) < 40
    agent_l = (agent or "").strip().lower()
    if not hidden and not wanted and tiny and "grok-bridge" not in agent_l and "grok bridge" not in agent_l:
        select_attempt = _select_sidebar_thread(hwnd, "grok-bridge")
        thinking, working_name = _find_working(hwnd)
        agent = _read_agent_name(hwnd, working_name)
        raw, last = _read_document_text(hwnd)
        tiny = len((raw or "").strip()) < 40
    prompt_val = _prompt_value(hwnd)
    text = _strip_prompt_garbage(raw, prompt_val)
    text = _redact_secrets(text)

    # limit is CHARACTERS from the END, not messages. Callers who pass 20/40
    # almost certainly meant "last N messages" — coerce so a nonce search
    # is not truncated to the last 20 chars ("replied ").
    LIMIT_HINT = (
        "`limit` is characters from the end, not messages. "
        "Values under 500 are coerced. Pass 4000+ to search for a nonce."
    )
    limit = args.get("limit")
    try:
        limit_n = int(limit) if limit is not None and str(limit).strip() != "" else None
    except (TypeError, ValueError):
        limit_n = None
    if limit_n is not None and limit_n < 0:
        limit_n = None
    limit_meta = {}
    if limit_n:
        limit_meta["limitRequested"] = limit_n
        if limit_n < 500:
            chars = max(limit_n * 250, 4000)
            limit_meta["limitCoerced"] = True
        else:
            chars = limit_n
        limit_meta["limitChars"] = chars
        text = text[-chars:]

    ok = bool(text) or _callback_ok(last)
    focused = agent
    if wanted and not ok:
        err = "thread_select_failed"
    elif not text.strip():
        err = "focused_thread_empty"
    else:
        err = None

    if not ok and not text:
        out = {
            "success": False,
            "errorCode": "transcript_unavailable",
            "agentName": agent,
            "agent": agent,
            "focusedThread": focused,
            "thinking": bool(thinking),
            "idle": (not thinking),
            "text": "",
            "length": 0,
            "hwnd": hwnd,
            "title": _title_of(win),
            "windowEtiquette": "background",
            "_hint": (
                "desktop_ui_text {hwnd, role:Document, range:all} returned no text. "
                "After cold launch the focused thread may not be grok-bridge. "
                "Pass args.thread / args.agent to click that sidebar row, or grok_open then wait. "
                "Never OCR-first. Never CDP 9222. "
                + LIMIT_HINT
                + " Default 4000+ when polling for a nonce."
            ),
            "_next": "grok_open {} then wait, or grok_chat_read {\"thread\":\"grok-bridge\"}",
        }
        if select_attempt is not None:
            out["selectAttempt"] = select_attempt
        out.update(limit_meta)
        return out

    out = {
        "success": True if text.strip() else False,
        "agentName": agent,
        "agent": agent,
        "focusedThread": focused,
        "thinking": bool(thinking),
        "idle": (not thinking),
        "text": text,
        "length": len(text),
        "hwnd": hwnd,
        "title": _title_of(win),
        "windowEtiquette": "background",
        "_hint": (
            f"Read UIA Document range=all on Grok Bot hwnd={hwnd} "
            f"({len(text)} chars, focusedThread={focused!r}, "
            + ("thinking" if thinking else "idle")
            + "). Latest messages are at the end. Webhook keys redacted. "
            "Optional thread/agent clicks a sidebar row then re-reads. "
            "If empty after cold start, grok_open then wait or pass thread:grok-bridge. "
            + LIMIT_HINT
            + (" Default 4000+ when polling for a nonce." if not limit_n else "")
        ),
    }
    out.update(limit_meta)
    out.update(computer_fields(*computer_state_from_text(raw)))
    out["mainWindowHidden"] = hidden
    if focused is None:
        out["focusedThreadReason"] = ("main window is hidden; read in place (thread switching needs it shown)"
                                      if hidden else "no conversation heading on the page")
    if err and not text.strip():
        out["success"] = False
        out["errorCode"] = err
        out["_next"] = "grok_open {} then wait, or grok_chat_read {\"thread\":\"grok-bridge\"}"
    if wanted:
        out["requestedThread"] = wanted
    if select_attempt is not None:
        out["selectAttempt"] = select_attempt
    return out