Files
kefu/deploy/protocol-integration-20260916/payload/protocol_engine.py
T
2026-09-21 10:34:06 +08:00

307 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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 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):
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
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={}
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._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()):
lookup=getattr(self.sender,'lookup',None)
saved=lookup(state.get('dedup_key','')) if lookup else None
if saved and saved['status']=='confirmed':
self._pending.pop(key,None)
continue
if state.get('send_state')=='sending':state.update(send_state='uncertain',stage='receipt_check',last_error='上次发送结果待核对,禁止自动重发')
elif state.get('stage')=='generating':state.update(stage='queued',ready_at=time.time())
self._save()
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},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]
if self.needs_attention:
self._progress('协议已暂停 · 发送结果待核对')
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()
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 _upsert(self,message):
if message.get('account')!=self.account:return
key=session_key(self.account,message['conv_id']);event=message.get('dedup_key','')
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 old.get('send_state') in ('sending','uncertain','failed'):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')}
self._save()
self.log(f"[协议收信] {message.get('display_name') or message['conv_id']} · 已加入回复队列")
self._progress('协议已收到新消息 · 等待处理')
def poll_once(self):
if self.stop_event.is_set():return
messages=self.db.get_new_messages(self._since)
# Row order is authoritative even for late writes or equal timestamps.
for m in sorted(messages,key=lambda x:(str(x.get('account','')),int(x.get('rowid',0)))):self._upsert(m)
with self._lock:keys=sorted(self._pending,key=lambda k:self._pending[k].get('created_at',0))
for key in keys:
if self.stop_event.is_set() or self.needs_attention:break
self._process(key)
if self.stop_event.is_set():return
self._completed_polls+=1
self._listening_progress()
now=time.monotonic()
if not self.needs_attention and 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):
with self._lock:
if self.stop_event.is_set() or self._pending.get(key) is not state:return False
context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=100)
if conversation_contact_reason(context):
self._skip_contact(key,state.get('display_name',''),state['dedup_key']);return False
last=(context or {}).get('last_message')
if not last: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 state.get('send_state') in ('sending','uncertain','failed') or state.get('stage')=='error':return
if time.time()<float(state.get('ready_at',0)):return
if not self._current(key,state):return
if int(state.get('content_type') or 0) not in (0,1,2):
self._stage(key,'error','此消息类型需要人工处理','当前协议版只自动处理文本消息');return
if not state.get('reply_text'):
self._stage(key,'generating','根据当前会话生成回复')
context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=100)
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'):
with self._lock:state['awaiting_review']=True
self._stage(key,'awaiting_review',reason)
return
if time.time()<float(state.get('send_ready_at',0)):return
if not self._current(key,state):return
with self._lock:
if self._pending.get(key) is not state:return
state.update(send_state='sending',awaiting_review=False)
self._stage(key,'sending','原账号单聊发送')
try:
receipt=self.sender.send(state['dedup_key'],self.pid,self.account,state['conv_id'],state['reply_text'],guard=lambda:self._current(key,state))
except SendCancelled:
with self._lock:
if self._pending.get(key) is state:state['send_state']='';self._save()
return
except DeliveryUnknown as exc:
# Keep the send journal's unknown outcome, but a proven contact restriction
# isolates this conversation instead of blocking all other customers.
try:context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=100)
except Exception:context=None
if conversation_contact_reason(context):
self._skip_contact(key,state.get('display_name',''),state['dedup_key']);return
with self._lock:state['send_state']='uncertain';self.needs_attention=True
self._stage(key,'receipt_check','发送结果待人工核对,已暂停监听',str(exc));return
except DeliveryFailed as exc:
if rejected_contact_reason(str(exc)):
self._skip_contact(key,state.get('display_name',''),state['dedup_key'],str(exc));return
with self._lock:state['send_state']='failed'
self._stage(key,'failed','客户端拒绝发送,需人工核对',str(exc));return
except Exception as exc:
with self._lock:state['send_state']=''
self._stage(key,'error','发送前检查失败',str(exc));return
self.reply_count+=1
self.log(f"[协议发送成功] {state['display_name']} · 服务器消息 {receipt['serverId']}")
try:
from conversation_store import ConversationStore
from queue_log import QueueLog
store=ConversationStore(str(application_data_dir()/'conversations.json'))
store.set_display_name(key,state['display_name'])
store.append_exchange_once(key,state['staged_user_text'],state['reply_text'],state['dedup_key'])
QueueLog().append(key,state['display_name'],'发送成功',f"协议回执 {receipt['serverId']},原账号及文本已核对")
except Exception as exc:self.log('[协议归档] '+str(exc))
with self._lock:
if self._pending.get(key) is state:self._pending.pop(key,None)
self._save()
def _needs_review(self,state):
if self.send_mode=='review':return True,'人工审核后点击“通过并发送”'
import ai_config
text=(state.get('staged_user_text','')+'\n'+state.get('reply_text','')).lower()
if state.get('judge_risk')=='high':return True,'模型裁判评估为高风险'
for rule in getattr(ai_config,'AI_REVIEW_RULES',[]) or []:
if not isinstance(rule,dict) or not rule.get('enabled',True):continue
if any(str(w).strip().lower() in text for w in rule.get('keywords',[]) if str(w).strip()):return True,'命中审核规则:'+str(rule.get('label',''))
return False,''
def _generate(self,context):
import ai_config,ai_chat,model_router
if not ai_config.AI_ENABLED:return self.reply_text
context={**context,'text':model_context_text(context)}
if ai_chat.gateway_provider() is not None:
ai_chat.take_last_gateway_trace()
reply=ai_chat.get_ai_reply(chat_text=context['text'])
trace=ai_chat.take_last_gateway_trace()
key=session_key(self.account,context['conv_id'])
with self._lock:
if key in self._pending:
self._pending[key]['judge_risk']=str((trace.get('judge') or {}).get('risk') or '')
self._pending[key]['knowledge_trace']=trace.get('knowledge') or {}
self._pending[key]['model_task_id']=trace.get('task_id') or ''
return reply
provider=ai_chat.current_provider();judge=provider if getattr(ai_config,'AI_JUDGE_ENABLED',False) else None
if judge is None:return ai_chat.get_ai_reply(chat_text=context['text'])
result=model_router.answer(chat_text=context['text'],answer_providers=[provider],judge_provider=judge,judge_mode=getattr(ai_config,'AI_JUDGE_MODE','shadow'),customer_text=context['last_message']['content'])
key=session_key(self.account,context['conv_id'])
with self._lock:
if key in self._pending:self._pending[key]['judge_risk']=str((result.get('judge') or {}).get('risk') or '')
return result.get('reply','')
def _control(self,keys,action):
result={'deleted':[],'retried':[],'approved':[],'missing':[],'protected':[],'not_retryable':[],'not_pending':[],'scheduled':[]}
with self._lock:
for key in keys:
s=self._pending.get(key)
if s is None:result['missing'].append(key);continue
if s.get('send_state') in ('sending','uncertain','failed'):result['protected'].append(key);continue
if action=='delete':self._pending.pop(key,None);result['deleted'].append(key)
elif action=='approve':
if s.get('awaiting_review') and s.get('reply_text'):s.update(approved=True,awaiting_review=False);result['approved'].append(key)
else:result['not_pending'].append(key)
elif action=='retry':
if s.get('stage')=='error':s.update(stage='queued',last_error='',ready_at=time.time(),reply_text='',approved=False);result['retried'].append(key)
else:result['not_retryable'].append(key)
self._save();self._reply_wakeup.set()
return result
def cancel_pending_replies(self,keys):return self._control(keys,'delete')
def retry_pending_replies(self,keys):return self._control(keys,'retry')
def approve_pending_replies(self,keys):return self._control(keys,'approve')
def close(self):
self.sender.close()
if self._owned_db:self.db.close()