"""Database receive + native original-account send, without window interaction.""" from __future__ import annotations import hashlib import json import os from pathlib import Path import threading import time from runtime_paths import application_data_dir from reply_session_policy import excluded_session_reason from contact_reply_guard import CONTACT_REASON, conversation_contact_reason, system_contact_status, rejected_contact_reason from wecom_native_sender import NativeSender,NativeUnavailable,DeliveryUnknown,DeliveryFailed,SendCancelled,discover def protocol_queue_path():return application_data_dir()/'pending_replies.protocol.json' def model_context_text(context): """Respect saved memory settings while retaining the unanswered message batch.""" import ai_config from datetime import datetime messages=list(context.get('messages') or []) if not messages:return str(context.get('text') or '') start=len(messages)-1 while start>0 and not messages[start-1].get('is_self'):start-=1 if getattr(ai_config,'AI_CONTEXT_ENABLED',True): rounds=max(1,min(50,int(getattr(ai_config,'AI_CONTEXT_MAX_ROUNDS',5) or 5))) start=min(start,max(0,len(messages)-rounds*2)) lines=[] for item in messages[start:]: speaker='我' if item.get('is_self') else context.get('display_name','客户') stamp=datetime.fromtimestamp(float(item.get('send_time') or time.time())).strftime('%Y/%m/%d %H:%M:%S') lines.extend((f'{speaker} {stamp}',str(item.get('content') or ''))) return '\n'.join(lines) def _is_text_message(message): try:return int(message.get('content_type') or 0) in (0,1,2) except (TypeError,ValueError):return False MANUAL_MEDIA_REASON = '当前未回复消息包含图片、语音或其他非文本内容,协议版需人工处理' MANUAL_BATCH_REASON = '连续未回复消息超过 100 条,无法保证问题完整,请人工处理后继续' MANUAL_NON_TEXT_REASON = '当前协议版只自动处理文本消息' def _send_protection(state): """Use either durable field; missing/old schema must not enable a replay.""" status=str(state.get('send_state') or '') stage=str(state.get('stage') or '') if status=='sending' or stage=='sending':return 'sending' if status in ('uncertain','unknown','sent_uncommitted') or stage in ('receipt_check','uncertain','unknown','sent_uncommitted'): return 'uncertain' if status=='failed' or stage=='failed':return 'failed' return 'uncertain' if status else '' def _restore_review_details(state): # Existing persisted queues predate the desktop's explicit review fields. # Recover presentation metadata without granting approval or replaying sends. if state.get('awaiting_review') and not state.get('approved'): state['review_reason'] = str(state.get('review_reason') or state.get('stage_detail') or '人工审核后点击“通过并发送”') if state.get('stage') == 'error' and state.get('last_error') in ( MANUAL_MEDIA_REASON, MANUAL_BATCH_REASON, MANUAL_NON_TEXT_REASON): state.update(manual_required=True, manual_reason=state['last_error']) def _unanswered_manual_reason(context): # A text caption after media is still a media question. Human replies and # restored-contact notices separate the current batch from old history. count=0 for message in reversed((context or {}).get('messages') or []): if message.get('is_self') or system_contact_status(message)=='restored':break count+=1 if not _is_text_message(message):return MANUAL_MEDIA_REASON if count>100:return MANUAL_BATCH_REASON return '' def _session_exclusion(source): source=source if isinstance(source,dict) else {} return excluded_session_reason(name=source.get('display_name') or '', conv_id=source.get('conv_id') or '',metadata=source) def session_key(account,conversation):return hashlib.blake2b((account+'\0'+conversation).encode(),digest_size=40).hexdigest() class ProtocolBot: def __init__(self,*,db=None,sender=None,identity=None,path=None,generate=None,stop_event=None,reply_text='',log=None,progress=None,review_assistant=None): self.stop_event=stop_event or threading.Event();self.log=log or print;self.reply_text=reply_text self.progress_cb=progress;self._progress_text='' self._completed_polls=0;self._last_poll_log=time.monotonic() self.identity=identity or discover();self.account=self.identity['accountId'];self.pid=self.identity['pid'] self.path=Path(path) if path else protocol_queue_path();self.path.parent.mkdir(parents=True,exist_ok=True) self._lock=threading.RLock();self._owned_db=db is None self._review_assistant=review_assistant;self._assistant_warning='' if db is None: from reply_database import LiveReplyDatabase db=LiveReplyDatabase._open_database(account=self.account) self.db=db if not self.db.health_check():raise NativeUnavailable('消息数据库不可读,协议监听不能启动') self.sender=sender or NativeSender(self.db,application_data_dir()/'protocol_sends.sqlite3') self.generate=generate or self._generate self.message_batch_window_seconds=8.;self.send_delay_seconds=2.;self.send_mode='auto' self.reply_count=0;self.false_pos_rows=[];self.security_verification_required=False self._window_ready=True;self._reply_wakeup=threading.Event();self.needs_attention=False self._since=time.time();self._pending={};self._seen={};self._contact_blocked={};self._manual_handoffs={};self._assistant_cancellations={} if self.path.exists(): raw=json.loads(self.path.read_text(encoding='utf-8')) if not isinstance(raw,dict) or not isinstance(raw.get('pending',{}),dict):raise NativeUnavailable('协议队列文件格式错误,已停止启动') self._pending={k:v for k,v in raw.get('pending',{}).items() if isinstance(v,dict)} self._manual_handoffs=raw.get('manual_handoffs',{}) if not isinstance(self._manual_handoffs,dict):raise NativeUnavailable('人工接管记录格式错误,已停止启动') self._assistant_cancellations=raw.get('assistant_cancellations',{}) if not isinstance(self._assistant_cancellations,dict):raise NativeUnavailable('审核提醒取消记录格式错误') self._assistant_cancellations={k:v for k,v in self._assistant_cancellations.items() if isinstance(v,dict)} self._contact_blocked=raw.get('contactBlocked',{}) if not isinstance(self._contact_blocked,dict):raise NativeUnavailable('联系人跳过记录格式错误') self._seen=raw.get('seen',{}) if not isinstance(self._seen,dict):raise NativeUnavailable('协议去重记录格式错误') for key,state in list(self._pending.items()): # Filtering old drafts must never erase an attempted send's evidence. if state.get('account')==self.account and not _send_protection(state) and _session_exclusion(state): if state.get('dedup_key'):self._seen[key]=state['dedup_key'] self._pending.pop(key,None) continue _restore_review_details(state) lookup=getattr(self.sender,'lookup',None) saved=lookup(state.get('dedup_key','')) if lookup else None if saved and saved['status']=='confirmed': self._assistant_call('record_automated_send',state,saved.get('result') or {},default=None) self._pending.pop(key,None) continue if _send_protection(state) in ('sending','uncertain'):state.update(send_state='uncertain',stage='receipt_check',last_error=state.get('last_error') or '上次发送结果待核对,禁止自动重发') elif state.get('stage')=='generating':state.update(stage='queued',ready_at=time.time()) # A restart preserves the affected conversation's no-replay protection. self.needs_attention=any(s.get('account')==self.account and _send_protection(s)=='uncertain' for s in self._pending.values()) self._save() def _assistant_call(self,method,*args,default=False): """Optional coordination cannot stop the protocol listener or approve work.""" try: if self._review_assistant is None: from review_assistant import ReviewAssistantCoordinator self._review_assistant=ReviewAssistantCoordinator( root=self.path.parent,account=self.account,pid=self.pid,db=self.db,log=self.log) assistant=self._review_assistant if method not in ('cancel_review','record_automated_send') and not assistant.enabled():return default return getattr(assistant,method)(*args) except Exception as exc: warning=f'{method}:{type(exc).__name__}' if warning!=self._assistant_warning: self._assistant_warning=warning try:self.log('[医疗助理] 协作状态暂不可用,协议监听继续;'+warning) except Exception:pass return default def _queue_assistant_cancellation(self,state): # Persist only an exact episode target, never a conversation-wide retry # that could cancel a later independent risk review. review_id=state.get('assistant_review_id') if review_id: self._assistant_cancellations[review_id]={ 'account':state.get('account'), 'conv_id':state.get('conv_id'), 'assistant_review_id':review_id} def _flush_assistant_cancellations(self): with self._lock:items=list(self._assistant_cancellations.items()) for review_id,state in items: if state.get('account')!=self.account:continue if self._assistant_call('cancel_review',state) is not True:continue with self._lock: if self._assistant_cancellations.get(review_id) is not state:continue self._assistant_cancellations.pop(review_id,None) try:self._save() except Exception: self._assistant_cancellations[review_id]=state raise def _assistant_cancellation_pending(self,state): with self._lock: return any(item.get('account')==self.account and item.get('conv_id')==state.get('conv_id') for item in self._assistant_cancellations.values()) def _assistant_recipient(self,conv_id): return bool(self._assistant_call('is_recipient',conv_id)) def _assistant_pending(self,state): # Preserve an already-registered review if coordination storage is # temporarily unavailable; never interpret a read failure as approval. if state.get('assistant_review_id') and not state.get('approved'):return True return bool(self._assistant_call('has_pending',state.get('conv_id',''))) def _register_assistant_review(self,key,state,context=None): if not self._assistant_call('enabled') or state.get('assistant_review_id'):return if _send_protection(state) or state.get('account')!=self.account:return if not state.get('awaiting_review') or state.get('approved'):return if context is None: context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=500) review_id=self._assistant_call('ensure_review',state,context,default='') if review_id and review_id!=state.get('assistant_review_id'): with self._lock: if self._pending.get(key) is state: state['assistant_review_id']=review_id self._save() @staticmethod def _message_order(message): try:return (float(message.get('send_time') or 0),int(message.get('rowid') or 0)) except (TypeError,ValueError):return (0.0,0) def _assistant_context(self,state,context): # A phone reply may reach the local DB after a newer customer question. # Only this proven resumed batch needs chronological context; normal DB # polling remains in authoritative row order. if not state.get('assistant_resume_evidence') or not context:return context messages=list(context.get('messages') or []) messages.sort(key=self._message_order) if not messages:return context updated={**context,'messages':messages,'last_message':messages[-1]} updated['text']=model_context_text(updated) return updated def _reconcile_assistant_review(self,key,state,context=None): if not self._assistant_call('enabled'):return False if (state.get('account')!=self.account or _send_protection(state) or not (state.get('awaiting_review') or state.get('assistant_review_id') or self._assistant_call('has_pending',state.get('conv_id','')))): return False if context is None: context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=500) self._register_assistant_review(key,state,context) evidence=self._assistant_call('reconcile',state,context,default=None) if not isinstance(evidence,dict) or not evidence:return False if (evidence.get('review_id') and state.get('assistant_review_id') and evidence['review_id']!=state['assistant_review_id']):return False evidence_order=self._message_order(evidence) following=[m for m in (context or {}).get('messages',[]) if m.get('account')==self.account and m.get('conv_id')==state.get('conv_id') and m.get('is_self') is False and m.get('dedup_key') and self._message_order(m)>evidence_order] followup=max(following,key=self._message_order) if following else None with self._lock: if self._pending.get(key) is not state or _send_protection(state):return False previous_seen=self._seen.get(key) if state.get('dedup_key'):self._seen[key]=state['dedup_key'] self._pending.pop(key,None) try: if followup: if self._seen.get(key)==followup['dedup_key']:self._seen.pop(key,None) self._upsert({**followup,'assistant_resume_evidence':evidence}) else:self._save() except Exception: self._pending[key]=state if previous_seen is None:self._seen.pop(key,None) else:self._seen[key]=previous_seen raise try: from queue_log import QueueLog QueueLog().append(key,state.get('display_name',''),'已审核', '已检测到当前企微账号人工回复;本轮旧AI草稿已丢弃,后续消息重新执行风控') self.log('[协议已审核] '+state.get('display_name','')+' · 已人工回复,本轮旧AI草稿已丢弃') except Exception:pass self._listening_progress() return True def _save(self): with self._lock: temp=self.path.with_suffix('.tmp') with temp.open('w',encoding='utf-8') as f: json.dump({'schemaVersion':1,'transport':'protocol','pending':self._pending,'seen':self._seen,'contactBlocked':self._contact_blocked,'manual_handoffs':self._manual_handoffs,'assistant_cancellations':self._assistant_cancellations},f,ensure_ascii=False,indent=2);f.flush();os.fsync(f.fileno()) os.replace(temp,self.path) def _progress(self,text): if self.stop_event.is_set() or text==self._progress_text:return self._progress_text=text if self.progress_cb: try:self.progress_cb(text) except Exception:pass # Display failures must not interrupt delivery. def _listening_progress(self): with self._lock: pending=[s for s in self._pending.values() if s.get('account')==self.account] uncertain=sum(_send_protection(s)=='uncertain' for s in pending) self.needs_attention=bool(uncertain) if uncertain: self._progress(f'协议监听中 · {uncertain} 个会话发送结果待核对,其他会话继续处理') elif any(s.get('stage') not in ('awaiting_review','error','failed','receipt_check') for s in pending): self._progress(f'协议监听中 · 待处理 {len(pending)} 个会话') elif any(s.get('awaiting_review') for s in pending): count=sum(bool(s.get('awaiting_review')) for s in pending) self._progress(f'协议监听中 · {count} 个会话待审核') elif pending: self._progress(f'协议监听中 · {len(pending)} 个会话待人工处理') else: self._progress('协议监听中 · 等待新消息') def _stage(self,key,stage,detail='',error=''): with self._lock: if key not in self._pending:return s=self._pending[key];now=time.time();s.update(stage=stage,stage_detail=detail,stage_started_at=now,updated_at=now,last_error=error) self._save() if stage=='awaiting_review':self._register_assistant_review(key,s) phase={'queued':'等待消息合并','generating':'正在生成回复','awaiting_review':'等待审核', 'sending':'正在协议发送并核对回执','receipt_check':'发送结果待人工核对', 'failed':'发送失败,待人工处理','error':'处理异常,待人工处理'}.get(stage,stage) self._progress(f"{phase} · {s.get('display_name','')}") self.log(f"[协议处理] {s.get('display_name','')} · {detail or phase}"+(f';{error}' if error else '')) try: from queue_log import QueueLog QueueLog().append(key,s.get('display_name',''),{'queued':'入队','generating':'生成回复','awaiting_review':'待审核','sending':'发送','receipt_check':'核对回执','failed':'发送失败'}.get(stage,stage),detail or error) except Exception:pass def _skip_contact(self,key,display_name,event='',reason=CONTACT_REASON): with self._lock: self._contact_blocked[key]=time.time() self._pending.pop(key,None) if event:self._seen[key]=event self._save() self.log(f"[协议跳过] {display_name} · {reason}") try: from queue_log import QueueLog QueueLog().append(key,display_name,'跳过',reason) except Exception:pass def _skip_excluded_session(self,key,source,*,expected_state=None,reason=''): reason=reason or _session_exclusion(source) if not reason:return False with self._lock: state=self._pending.get(key) if state is not None: if state.get('account')!=self.account or _send_protection(state):return True if expected_state is not None and state is not expected_state:return True event=source.get('dedup_key') or (state or {}).get('dedup_key') or '' changed=state is not None or (event and self._seen.get(key)!=event) if not changed:return True self._pending.pop(key,None) if event:self._seen[key]=event self._save() self.log(f"[协议跳过] {source.get('display_name') or (state or {}).get('display_name','')} · {reason}") return True def _upsert(self,message): if message.get('account')!=self.account:return key=session_key(self.account,message['conv_id']);event=message.get('dedup_key','') # Neither new messages nor contact notices resolve an unknown send. with self._lock: old=self._pending.get(key) if old and _send_protection(old):return if self._assistant_recipient(message['conv_id']): with self._lock: self._pending.pop(key,None) if event:self._seen[key]=event self._save() return if old and message.get('is_self'): context=self.db.get_conversation_context_by_id(self.account,message['conv_id'],limit=500) if self._reconcile_assistant_review(key,old,context):return if self._assistant_pending(old):return if self._skip_excluded_session(key,message):return status=system_contact_status(message) if status=='blocked': self._skip_contact(key,message.get('display_name',''),event);return with self._lock: if status=='restored': self._contact_blocked.pop(key,None) self._seen[key]=event;self._save();return blocked_at=float(self._contact_blocked.get(key,0) or 0) if blocked_at and 0<=time.time()-blocked_at<6*3600: if event:self._seen[key]=event self._pending.pop(key,None);self._save();return self._contact_blocked.pop(key,None) old=self._pending.get(key) if old and _send_protection(old):return if message.get('is_self'): if old:self._pending.pop(key,None);self._save() return if not event or self._seen.get(key)==event:return now=time.time();self._seen[key]=event self._pending[key]={'transport':'protocol','account':self.account,'conv_id':message['conv_id'],'display_name':message.get('display_name') or message['conv_id'], 'dedup_key':event,'created_at':old.get('created_at',now) if old else now,'updated_at':now,'ready_at':now+self.message_batch_window_seconds, 'stage':'queued','stage_started_at':now,'send_state':'','chat_text':'','last_lines':[message.get('content','')],'staged_user_text':message.get('content',''), 'detected_by':'protocol_db','detection_ts':now,'reply_text':'','awaiting_review':False,'content_type':message.get('content_type'), 'database_event':dict(message),'assistant_resume_evidence':message.get('assistant_resume_evidence')} self._save() self.log(f"[协议收信] {message.get('display_name') or message['conv_id']} · 已加入回复队列") self._progress('协议已收到新消息 · 等待处理') def _isolate_task_error(self,key,error,expected_state=None): """Keep a failed conversation out of automatic replay, without stopping peers.""" detail='本会话处理异常,其他会话继续监听' stage='error' with self._lock: state=self._pending.get(key) if state is not None and state.get('account')==self.account and ( expected_state is None or state is expected_state): protection=_send_protection(state) if protection in ('sending','uncertain'): state['send_state']='uncertain';self.needs_attention=True stage='receipt_check' detail='本会话发送结果待人工核对,其他会话继续监听' elif protection=='failed': stage='failed' try:self._stage(key,stage,detail,str(error)) except Exception: # _stage updates memory before persisting. A storage/logging # failure must not let this conversation send again in this run. state.update(stage=stage,last_error=str(error),stage_detail=detail) try:self.log(f'[协议会话异常] {detail};{type(error).__name__}: {error}') except Exception:pass def poll_once(self): if self.stop_event.is_set():return try:self._flush_assistant_cancellations() except Exception as exc:self._isolate_task_error(None,exc) messages=self.db.get_new_messages(self._since) # A malformed row/task belongs to one conversation, not the whole listener. incoming=[] for message in messages: try: if not isinstance(message,dict):raise ValueError('消息记录格式错误') if message.get('account')!=self.account:continue if not isinstance(message.get('conv_id'),str) or not message['conv_id']: raise ValueError('消息缺少会话标识') incoming.append((int(message.get('rowid',0)),message)) except Exception as exc:self._isolate_task_error(None,exc) # Row order is authoritative even for late writes or equal timestamps. for _,message in sorted(incoming,key=lambda item:item[0]): if self.stop_event.is_set():return key=session_key(self.account,message['conv_id']) try:self._upsert(message) except Exception as exc:self._isolate_task_error(key,exc) def created_at(state): try:return float(state.get('created_at',0) or 0) except (TypeError,ValueError):return 0 with self._lock:tasks=sorted(self._pending.items(),key=lambda item:created_at(item[1])) for key,state in tasks: if self.stop_event.is_set():break try:self._process(key) except Exception as exc:self._isolate_task_error(key,exc,expected_state=state) if self.stop_event.is_set():return self._assistant_call('enqueue_notifications',default=None) self._completed_polls+=1 self._listening_progress() now=time.monotonic() if now-self._last_poll_log>=60: self._last_poll_log=now with self._lock:pending=sum(s.get('account')==self.account for s in self._pending.values()) self.log(f'[协议监听] 消息库轮询正常;已检查 {self._completed_polls} 轮,待处理 {pending} 个会话,已确认发送 {self.reply_count} 条。') def _current(self,key,state,*,excluded=None): with self._lock: if self.stop_event.is_set() or self._pending.get(key) is not state:return False if self._assistant_recipient(state['conv_id']): if excluded is not None:excluded.append('配置的医疗助理会话不参与自动回复') return False if self._skip_excluded_session(key,state,expected_state=state): if excluded is not None:excluded.append(_session_exclusion(state)) return False context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=100) context=self._assistant_context(state,context) for source in (context or {},(context or {}).get('last_message') or {}): if self._skip_excluded_session(key,source,expected_state=state): if excluded is not None:excluded.append(_session_exclusion(source)) return False if conversation_contact_reason(context): self._skip_contact(key,state.get('display_name',''),state['dedup_key']);return False if self._reconcile_assistant_review(key,state,context):return False last=(context or {}).get('last_message') if not last:return False if last.get('is_self') and self._assistant_pending(state):return False if last.get('is_self') or last.get('dedup_key')!=state['dedup_key']: # A new customer message invalidates an earlier draft and its approval. with self._lock: if self._pending.get(key) is state and state.get('send_state')!='uncertain': state['send_state']='';self._pending.pop(key,None) self._seen.pop(key,None);self._save() self._upsert(last);return False return True def _process(self,key): with self._lock:state=self._pending.get(key) if not state or state.get('account')!=self.account:return if _send_protection(state) or self._assistant_cancellation_pending(state):return if self._assistant_recipient(state['conv_id']): with self._lock: if self._pending.get(key) is state:self._pending.pop(key,None);self._save() return if self._reconcile_assistant_review(key,state):return if self._skip_excluded_session(key,state,expected_state=state):return if state.get('stage') in ('error','manual_takeover'):return if state.get('manual_required') or state.get('foreign_draft_text') or state.get('foreign_draft_at') is not None:return if time.time()=101:break with self._lock: state.update(reply_text='',staged_reply_text='',approved=False,awaiting_review=False, review_reason='',manual_required=True,manual_reason=manual_reason, chat_text=str((context or {}).get('text') or ''),last_lines=list(reversed(lines))) self._stage(key,'error','当前消息需要人工处理',manual_reason);return if not state.get('reply_text'): self._stage(key,'generating','根据当前会话生成回复') try: answer=self.generate(context) if not isinstance(answer,str) or not answer.strip() or answer.strip().startswith(('__VISION_', '__NO_INCOMING_MESSAGE__', '__VOICE_NOT_TRANSCRIBED__')):raise ValueError('模型未返回有效文本') except Exception as exc:self._stage(key,'error','模型生成失败,可重试',str(exc));return if not self._current(key,state):return with self._lock: state.update(reply_text=answer.strip(),staged_reply_text=answer.strip(),chat_text=context['text'],send_ready_at=time.time()+self.send_delay_seconds) self._save() review,reason=self._needs_review(state) if review and not state.get('approved'): if not state.get('awaiting_review') or state.get('review_reason')!=reason: with self._lock:state.update(awaiting_review=True,review_reason=reason,manual_required=False,manual_reason='') self._stage(key,'awaiting_review',reason) return if not review and (state.get('awaiting_review') or state.get('review_reason')): with self._lock:state.update(awaiting_review=False,review_reason='') self._stage(key,'ready_to_send','审核策略已更新,等待发送前核对') if time.time()