307 lines
19 KiB
Python
307 lines
19 KiB
Python
"""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() |