feat(wechat): add send_file via CDN upload; fix _strip_md robustness

- Add WxBotClient.send_file() with AES-128-ECB encryption and CDN upload
- Extract [FILE:] tags before _clean() to preserve filenames
- Fix _strip_md: support 3+ backticks via backreference, stop stripping underscores
- Remove verbose key_info echo from update_working_checkpoint
This commit is contained in:
Liang Jiaqing
2026-03-23 17:55:20 +08:00
parent eb8834b9df
commit 60f40e3c5e
2 changed files with 63 additions and 14 deletions

View File

@@ -1,7 +1,10 @@
import os, sys, re, threading, queue, time, socket, json, struct, base64, uuid, webbrowser import os, sys, re, threading, queue, time, socket, json, struct, base64, uuid, webbrowser, hashlib, math
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import requests, qrcode import requests, qrcode
from Crypto.Cipher import AES
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
# ── WxBotClient (inline from wx_bot_client.py) ── # ── WxBotClient (inline from wx_bot_client.py) ──
@@ -9,6 +12,8 @@ API = 'https://ilinkai.weixin.qq.com'
TOKEN_FILE = Path.home() / '.wxbot' / 'token.json' TOKEN_FILE = Path.home() / '.wxbot' / 'token.json'
TOKEN_FILE.parent.mkdir(exist_ok=True) TOKEN_FILE.parent.mkdir(exist_ok=True)
VER, MSG_USER, MSG_BOT, ITEM_TEXT, STATE_FINISH = '0.2.5', 1, 2, 1, 2 VER, MSG_USER, MSG_BOT, ITEM_TEXT, STATE_FINISH = '0.2.5', 1, 2, 1, 2
ITEM_FILE = 4
CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c'
def _uin(): def _uin():
return base64.b64encode(str(struct.unpack('>I', os.urandom(4))[0]).encode()).decode() return base64.b64encode(str(struct.unpack('>I', os.urandom(4))[0]).encode()).decode()
@@ -32,8 +37,7 @@ class WxBotClient:
self._tf.write_text(json.dumps(d, ensure_ascii=False, indent=2), 'utf-8') self._tf.write_text(json.dumps(d, ensure_ascii=False, indent=2), 'utf-8')
def _post(self, ep, body, timeout=15): def _post(self, ep, body, timeout=15):
h = {'Content-Type': 'application/json', 'AuthorizationType': 'ilink_bot_token', h = {'Content-Type': 'application/json', 'AuthorizationType': 'ilink_bot_token', 'X-WECHAT-UIN': _uin()}
'X-WECHAT-UIN': _uin()}
if self.token: h['Authorization'] = f'Bearer {self.token}' if self.token: h['Authorization'] = f'Bearer {self.token}'
r = requests.post(f'{API}/{ep}', json=body, headers=h, timeout=timeout) r = requests.post(f'{API}/{ep}', json=body, headers=h, timeout=timeout)
r.raise_for_status() r.raise_for_status()
@@ -51,11 +55,8 @@ class WxBotClient:
last = '' last = ''
while True: while True:
time.sleep(poll_interval) time.sleep(poll_interval)
try: try: s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, timeout=60).json()
s = requests.get(f'{API}/ilink/bot/get_qrcode_status', except requests.exceptions.ReadTimeout: continue
params={'qrcode': qr_id}, timeout=60).json()
except requests.exceptions.ReadTimeout:
continue
st = s.get('status', '') st = s.get('status', '')
if st != last: print(f' 状态: {st}'); last = st if st != last: print(f' 状态: {st}'); last = st
if st == 'confirmed': if st == 'confirmed':
@@ -93,6 +94,48 @@ class WxBotClient:
'to_user_id': to_user_id, 'typing_ticket': typing_ticket, 'to_user_id': to_user_id, 'typing_ticket': typing_ticket,
'typing_status': 2 if cancel else 1, 'base_info': {'channel_version': VER}}) 'typing_status': 2 if cancel else 1, 'base_info': {'channel_version': VER}})
def send_file(self, to_user_id, file_path, context_token=''):
"""Send a file to user via CDN upload."""
fp = Path(file_path)
raw = fp.read_bytes()
rawsize = len(raw)
rawfilemd5 = hashlib.md5(raw).hexdigest()
aes_key = os.urandom(16)
filekey = uuid.uuid4().hex
ciphertext_size = ((rawsize // 16) + 1) * 16
# 1. get upload url
resp = self._post('ilink/bot/getuploadurl', {
'filekey': filekey, 'media_type': 3, 'to_user_id': to_user_id,
'rawsize': rawsize, 'rawfilemd5': rawfilemd5,
'filesize': ciphertext_size, 'no_need_thumb': True,
'aeskey': aes_key.hex(),
'base_info': {'channel_version': VER}})
upload_param = resp.get('upload_param', '')
if not upload_param:
raise RuntimeError(f'getuploadurl failed: {resp}')
# 2. AES-128-ECB encrypt (PKCS7)
cipher = AES.new(aes_key, AES.MODE_ECB)
pad_len = 16 - (rawsize % 16)
ciphertext = cipher.encrypt(raw + bytes([pad_len] * pad_len))
# 3. upload to CDN
upload_url = (f'{CDN_BASE}/upload?encrypted_query_param='
f'{quote(upload_param)}&filekey={filekey}')
r = requests.post(upload_url, data=ciphertext, headers={'Content-Type': 'application/octet-stream'}, timeout=120)
r.raise_for_status()
download_param = r.headers.get('x-encrypted-param', '')
if not download_param:
raise RuntimeError(f'CDN upload: no x-encrypted-param. status={r.status_code}')
# 4. send message with file attachment
msg = {'from_user_id': '', 'to_user_id': to_user_id,
'client_id': f'pyclient-{uuid.uuid4().hex[:16]}',
'message_type': MSG_BOT, 'message_state': STATE_FINISH,
'item_list': [{'type': ITEM_FILE, 'file_item': {
'media': {'encrypt_query_param': download_param,
'aes_key': base64.b64encode(aes_key.hex().encode()).decode(), 'encrypt_type': 1},
'file_name': fp.name, 'len': str(rawsize)}}]}
if context_token: msg['context_token'] = context_token
return self._post('ilink/bot/sendmessage', {'msg': msg, 'base_info': {'channel_version': VER}})
@staticmethod @staticmethod
def extract_text(msg): def extract_text(msg):
return '\n'.join(it['text_item'].get('text', '') return '\n'.join(it['text_item'].get('text', '')
@@ -124,12 +167,12 @@ _TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'summary', 't
_TAG_PATS.append(r'<file_content>.*?</file_content>') _TAG_PATS.append(r'<file_content>.*?</file_content>')
def _strip_md(t): def _strip_md(t):
t = re.sub(r'```[\s\S]*?```', lambda m: m.group().strip('`').split('\n',1)[-1] if '\n' in m.group() else m.group().strip('`'), t) t = re.sub(r'(`{3,})[\s\S]*?\1', lambda m: m.group().strip('`').split('\n',1)[-1] if '\n' in m.group() else m.group().strip('`'), t)
t = re.sub(r'`([^`]+)`', r'\1', t) t = re.sub(r'`([^`]+)`', r'\1', t)
t = re.sub(r'!\[.*?\]\(.*?\)', '', t) t = re.sub(r'!\[.*?\]\(.*?\)', '', t)
t = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', t) t = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', t)
t = re.sub(r'^#{1,6}\s+', '', t, flags=re.M) t = re.sub(r'^#{1,6}\s+', '', t, flags=re.M)
t = re.sub(r'(\*{1,3}|_{1,3})(.*?)\1', r'\2', t) t = re.sub(r'(\*{1,3})(.*?)\1', r'\2', t)
t = re.sub(r'^\s*[-*+]\s+', '', t, flags=re.M) t = re.sub(r'^\s*[-*+]\s+', '', t, flags=re.M)
t = re.sub(r'^\s*\d+\.\s+', '', t, flags=re.M) t = re.sub(r'^\s*\d+\.\s+', '', t, flags=re.M)
t = re.sub(r'^\s*>\s?', '', t, flags=re.M) t = re.sub(r'^\s*>\s?', '', t, flags=re.M)
@@ -191,12 +234,20 @@ def on_message(bot, msg):
if 'done' in item: result = item['done']; break if 'done' in item: result = item['done']; break
except queue.Empty: except queue.Empty:
result = '[超时]' result = '[超时]'
# Extract files BEFORE _clean (which strips underscores via markdown removal)
files = re.findall(r'\[FILE:([^\]]+)\]', result)
show = _clean(result) show = _clean(result)
show = re.sub(r'\[FILE:[^\]]+\]', '', show).strip() or '...'
for chunk in _split(show): for chunk in _split(show):
try: bot.send_text(uid, chunk, context_token=ctx) try: bot.send_text(uid, chunk, context_token=ctx)
except Exception as e: print(f'[WX] send err: {e}') except Exception as e: print(f'[WX] send err: {e}', file=sys.__stdout__)
time.sleep(0.3) time.sleep(0.3)
for fpath in files:
if not os.path.isabs(fpath): fpath = os.path.join(_TEMP_DIR, fpath)
try:
if not os.path.exists(fpath): raise FileNotFoundError(f"文件不存在: {fpath}")
bot.send_file(uid, fpath, context_token=ctx)
print(f'[WX] sent file: {fpath}', file=sys.__stdout__)
except Exception as e: print(f'[WX] send_file err: {e}', file=sys.__stdout__)
threading.Thread(target=_handle, daemon=True).start() threading.Thread(target=_handle, daemon=True).start()

2
ga.py
View File

@@ -430,8 +430,6 @@ class GenericAgentHandler(BaseHandler):
if "related_sop" in args: self.working['related_sop'] = related_sop if "related_sop" in args: self.working['related_sop'] = related_sop
self.working['passed_sessions'] = 0 self.working['passed_sessions'] = 0
yield f"[Info] Updated key_info and related_sop.\n" yield f"[Info] Updated key_info and related_sop.\n"
yield f"key_info:\n{self.working.get('key_info', '')}\n\n"
yield f"related_sop:\n{self.working.get('related_sop', '')}\n\n"
next_prompt = self._get_anchor_prompt() next_prompt = self._get_anchor_prompt()
#next_prompt += '\n[SYSTEM TIPS] 此函数一般在任务开始或中间时调用如果任务已成功完成应该是start_long_term_update用于结算长期记忆。\n' #next_prompt += '\n[SYSTEM TIPS] 此函数一般在任务开始或中间时调用如果任务已成功完成应该是start_long_term_update用于结算长期记忆。\n'
return StepOutcome({"status": "success"}, next_prompt=next_prompt) return StepOutcome({"status": "success"}, next_prompt=next_prompt)