234 lines
12 KiB
Plaintext
234 lines
12 KiB
Plaintext
def _review_assistant_settings(self):
|
||
loader = getattr(self, "_review_assistant_settings_loader", None)
|
||
if loader is not None:
|
||
return loader()
|
||
try:
|
||
with open(os.path.join(_SCRIPT_DIR, "app_settings.json"), encoding="utf-8") as handle:
|
||
return json.load(handle)
|
||
except (OSError, ValueError):
|
||
return {}
|
||
|
||
def _review_assistant_warn(self, detail):
|
||
if detail != getattr(self, "_review_assistant_warning", None):
|
||
self._review_assistant_warning = detail
|
||
print(" [助理审核] " + detail + ";保留人工审核,不猜测人工回复。")
|
||
|
||
def _get_review_assistant(self, *, verify=False):
|
||
"""Bind only to the live window's verified login, never a browser account."""
|
||
try:
|
||
settings = self._review_assistant_settings()
|
||
configured = settings.get("review_assistant_accounts") if isinstance(settings, dict) else None
|
||
if not isinstance(configured, dict) or not any(
|
||
isinstance(value, dict) and value.get("enabled") for value in configured.values()):
|
||
self.close_review_assistant()
|
||
return None
|
||
current = getattr(self, "_review_assistant", None)
|
||
if current is not None and not verify:
|
||
return current if current.enabled() else None
|
||
from wecom_native_sender import NativeClient
|
||
_, pid = win32process.GetWindowThreadProcessId(self.hwnd)
|
||
with NativeClient(pid) as client:
|
||
account = str(client.account)
|
||
identity = (account, int(pid))
|
||
if identity != getattr(self, "_review_assistant_identity", None):
|
||
self.close_review_assistant()
|
||
from review_assistant import ReviewAssistantCoordinator
|
||
current = ReviewAssistantCoordinator(
|
||
root=_SCRIPT_DIR, settings_loader=self._review_assistant_settings,
|
||
account=account, pid=pid, db=getattr(self, "_db_source", None),
|
||
log=lambda message: print(" [助理审核] " + str(message)),
|
||
)
|
||
self._review_assistant = current
|
||
self._review_assistant_identity = identity
|
||
self._review_assistant_warning = None
|
||
return current if current is not None and current.enabled() else None
|
||
except Exception as exc:
|
||
self.close_review_assistant()
|
||
self._review_assistant_warn("当前账号或本机协议不可核验(" + type(exc).__name__ + ")")
|
||
return None
|
||
|
||
def close_review_assistant(self):
|
||
assistant = getattr(self, "_review_assistant", None)
|
||
self._review_assistant = None
|
||
self._review_assistant_identity = None
|
||
if assistant is not None:
|
||
try:
|
||
assistant.close()
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _review_assistant_protected(state):
|
||
return bool(state.get("send_state") or state.get("send_dispatched_at") or
|
||
str(state.get("stage") or "") in {
|
||
"sending", "receipt_check", "uncertain", "unknown", "sent_uncommitted", "failed"})
|
||
|
||
def _review_assistant_context(self, state, *, fp=None):
|
||
source = getattr(self, "_db_source", None)
|
||
identity = getattr(self, "_review_assistant_identity", None)
|
||
if source is None or not identity:
|
||
return None
|
||
account = str(identity[0])
|
||
event = state.get("database_event") or {}
|
||
bound_account = str(state.get("account") or event.get("account") or "")
|
||
conv_id = str(state.get("conv_id") or event.get("conv_id") or "")
|
||
if bound_account and bound_account != account:
|
||
return None
|
||
if conv_id:
|
||
context = source.get_conversation_context_by_id(account, conv_id, limit=120)
|
||
else:
|
||
# This API rejects duplicate names even when an account is supplied.
|
||
name = str(state.get("display_name") or "")
|
||
context = source.get_conversation_context(name, account=account, limit=120) if name else None
|
||
if (not isinstance(context, dict) or str(context.get("account") or "") != account
|
||
or not context.get("conv_id") or (conv_id and context.get("conv_id") != conv_id)):
|
||
return None
|
||
if fp is not None:
|
||
state["account"] = account
|
||
state["conv_id"] = str(context["conv_id"])
|
||
return context
|
||
|
||
def _review_assistant_recipient(self, state):
|
||
assistant = self._get_review_assistant()
|
||
if assistant is None:
|
||
return False
|
||
try:
|
||
context = self._review_assistant_context(state)
|
||
return bool(context and assistant.is_recipient(context["conv_id"]))
|
||
except Exception:
|
||
return False
|
||
|
||
def _review_assistant_resumed(self, state):
|
||
assistant = self._get_review_assistant(verify=True)
|
||
if assistant is None or not isinstance(state, dict):
|
||
return False
|
||
try:
|
||
context = self._review_assistant_context(state)
|
||
return bool(context and assistant.resumed(context["conv_id"]))
|
||
except Exception:
|
||
return False
|
||
|
||
def _register_assistant_review(self, fp):
|
||
state = self._pending_reply_state(fp)
|
||
if not state or not state.get("awaiting_review") or self._review_assistant_protected(state):
|
||
return
|
||
assistant = self._get_review_assistant(verify=True)
|
||
if assistant is None:
|
||
return
|
||
try:
|
||
context = self._review_assistant_context(state, fp=fp)
|
||
if not context:
|
||
self._review_assistant_warn("无法按当前账号核对待审核会话")
|
||
return
|
||
if assistant.is_recipient(context["conv_id"]):
|
||
return
|
||
# Bind the task identity durably before a reminder may be queued.
|
||
if not self._persist_pending_replies():
|
||
return
|
||
review_id = assistant.ensure_review(state, context)
|
||
if review_id:
|
||
state["review_assistant_id"] = str(review_id)
|
||
self._persist_pending_replies()
|
||
assistant.enqueue_notifications()
|
||
except Exception as exc:
|
||
self._review_assistant_warn("审核提醒登记暂不可用(" + type(exc).__name__ + ")")
|
||
|
||
def poll_review_assistant(self):
|
||
"""Observe phone replies without moving the window or sending the old draft."""
|
||
now = time.monotonic()
|
||
if now < getattr(self, "_review_assistant_poll_at", 0.0):
|
||
return
|
||
self._review_assistant_poll_at = now + 2.0
|
||
stop = getattr(self, "_stop_check", None)
|
||
if stop is not None and stop.is_set():
|
||
return
|
||
assistant = self._get_review_assistant(verify=True)
|
||
if assistant is None:
|
||
return
|
||
from contextlib import nullcontext
|
||
for key, state in list(getattr(self, "_pending_reply_sessions", {}).items()):
|
||
if not state.get("awaiting_review") or self._review_assistant_protected(state):
|
||
continue
|
||
try:
|
||
fp = bytes.fromhex(key)
|
||
context = self._review_assistant_context(state, fp=fp)
|
||
if not context or assistant.is_recipient(context["conv_id"]):
|
||
continue
|
||
review_id = assistant.ensure_review(state, context)
|
||
if not review_id:
|
||
continue
|
||
state["review_assistant_id"] = str(review_id)
|
||
evidence = assistant.reconcile(state, context)
|
||
if not evidence:
|
||
continue
|
||
with getattr(self, "_pending_lock", None) or nullcontext():
|
||
if (self._pending_reply_state(fp) is not state or not state.get("awaiting_review")
|
||
or self._review_assistant_protected(state)):
|
||
continue
|
||
discarded = getattr(self, "_review_assistant_discarded_drafts", None)
|
||
if discarded is None:
|
||
discarded = self._review_assistant_discarded_drafts = {}
|
||
draft_key = str(context["account"]) + "\0" + str(context["conv_id"])
|
||
previous = discarded.get(draft_key)
|
||
discarded[draft_key] = {"account": context["account"], "conv_id": context["conv_id"],
|
||
"draft": str(state.get("reply_text") or state.get("staged_reply_text") or ""),
|
||
"review_id": str(review_id), "completed_at": time.time()}
|
||
# Remove only this exact old task; a concurrent replacement must survive.
|
||
self._pending_reply_sessions.pop(key)
|
||
if not self._persist_pending_replies():
|
||
self._pending_reply_sessions[key] = state
|
||
if previous is None:
|
||
discarded.pop(draft_key, None)
|
||
else:
|
||
discarded[draft_key] = previous
|
||
continue
|
||
getattr(self, "_pending_scan_progress", {}).pop(key, None)
|
||
getattr(self, "_pending_exchanges", {}).pop(key, None)
|
||
self.queue_log.append(self.session_id_of(fp), str(state.get("display_name") or "客户"),
|
||
"已审核", "已核对同账号人工回复,旧审核草稿已作废;后续新消息重新经过风控")
|
||
# Preserve customer messages arriving after the proven human reply.
|
||
last = context.get("last_message") or {}
|
||
if last and not last.get("is_self") and last.get("dedup_key"):
|
||
self.enqueue_external_reply(key, display_name=str(context.get("display_name") or ""),
|
||
chat_text=str(context.get("text") or ""), dedup_key=str(last["dedup_key"]),
|
||
detected_by="人工审核续接", database_event={
|
||
"account": context["account"], "conv_id": context["conv_id"],
|
||
"message_id": last["dedup_key"], "send_time": last.get("send_time"),
|
||
"name_unique": False})
|
||
except Exception as exc:
|
||
self._review_assistant_warn("人工回复核验暂不可用(" + type(exc).__name__ + ")")
|
||
assistant.enqueue_notifications()
|
||
|
||
def _is_discarded_review_draft(self, state, draft):
|
||
"""Called only after normal composer identity and focus checks have passed."""
|
||
if not draft or self._review_assistant_protected(state or {}):
|
||
return False
|
||
try:
|
||
if self._get_review_assistant(verify=True) is None:
|
||
return False
|
||
context = self._review_assistant_context(state or {})
|
||
if not context:
|
||
return False
|
||
key = str(context["account"]) + "\0" + str(context["conv_id"])
|
||
entry = getattr(self, "_review_assistant_discarded_drafts", {}).get(key) or {}
|
||
# Strict raw comparison: edited human text is never eligible for deletion.
|
||
return bool(entry.get("draft") and str(draft).replace("\r\n", "\n") ==
|
||
str(entry["draft"]).replace("\r\n", "\n"))
|
||
except Exception:
|
||
return False
|
||
|
||
def _record_assistant_automated_send(self, state):
|
||
assistant = self._get_review_assistant()
|
||
if assistant is None or not isinstance(state, dict):
|
||
return
|
||
try:
|
||
context = self._review_assistant_context(state)
|
||
if context:
|
||
assistant.record_automated_send({**state, "account": context["account"],
|
||
"conv_id": context["conv_id"]}, {"status": "confirmed", "transport": "visual",
|
||
"confirmedAt": time.time(), "text": str(state.get("reply_text") or state.get("staged_reply_text") or ""),
|
||
"messages": context.get("messages") or []})
|
||
except Exception as exc:
|
||
self._review_assistant_warn("自动回复留痕暂不可用(" + type(exc).__name__ + ")")
|
||
|