476 lines
17 KiB
Python
476 lines
17 KiB
Python
import asyncio
|
||
import os
|
||
from pathlib import Path
|
||
from weixin_bot import WeixinBot
|
||
|
||
# ---------- 发送图片的核心函数(不依赖 OpenClaw)----------
|
||
import httpx
|
||
import json
|
||
import base64
|
||
import random
|
||
import time
|
||
import hashlib
|
||
import uuid
|
||
from Crypto.Cipher import AES
|
||
from Crypto.Util.Padding import pad
|
||
from urllib.parse import quote
|
||
|
||
def calculate_encrypted_size(raw_size: int) -> int:
|
||
return ((raw_size + 1 + 15) // 16) * 16
|
||
|
||
def prepare_image_upload(image_path: str):
|
||
if not Path(image_path).exists():
|
||
return {"success": False, "error": f"图片文件不存在: {image_path}"}
|
||
with open(image_path, 'rb') as f:
|
||
file_data = f.read()
|
||
rawsize = len(file_data)
|
||
rawfilemd5 = hashlib.md5(file_data).hexdigest()
|
||
filekey = ''.join(random.choices('0123456789abcdef', k=32))
|
||
aeskey_hex = ''.join(random.choices('0123456789abcdef', k=32))
|
||
filesize = calculate_encrypted_size(rawsize)
|
||
return {
|
||
"success": True,
|
||
"filekey": filekey,
|
||
"aeskey_hex": aeskey_hex,
|
||
"rawsize": rawsize,
|
||
"rawfilemd5": rawfilemd5,
|
||
"filesize": filesize,
|
||
"file_data": file_data
|
||
}
|
||
|
||
def aes_encrypt_file(file_path: str, aes_key_hex: str) -> bytes:
|
||
aes_key = bytes.fromhex(aes_key_hex)
|
||
with open(file_path, 'rb') as f:
|
||
file_data = f.read()
|
||
cipher = AES.new(aes_key, AES.MODE_ECB)
|
||
padded_data = pad(file_data, AES.block_size, style='pkcs7')
|
||
encrypted_data = cipher.encrypt(padded_data)
|
||
return encrypted_data
|
||
|
||
def encode_aes_key(aes_key_hex: str) -> str:
|
||
hex_bytes = aes_key_hex.encode('utf-8')
|
||
return base64.b64encode(hex_bytes).decode('utf-8')
|
||
|
||
def get_upload_params(bot_token: str, filekey: str, media_type: int, to_user_id: str,
|
||
rawsize: int, rawfilemd5: str, filesize: int, aeskey_hex: str):
|
||
random_uint32 = os.urandom(4)
|
||
x_wechat_uin = base64.b64encode(random_uint32).decode('utf-8')
|
||
body = {
|
||
"filekey": filekey,
|
||
"media_type": media_type,
|
||
"to_user_id": to_user_id,
|
||
"rawsize": rawsize,
|
||
"rawfilemd5": rawfilemd5,
|
||
"filesize": filesize,
|
||
"no_need_thumb": True,
|
||
"aeskey": aeskey_hex,
|
||
"base_info": {"channel_version": "1.0.0"}
|
||
}
|
||
raw = json.dumps(body, ensure_ascii=False)
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"AuthorizationType": "ilink_bot_token",
|
||
"Authorization": f"Bearer {bot_token}",
|
||
"X-WECHAT-UIN": x_wechat_uin,
|
||
"Content-Length": str(len(raw.encode("utf-8"))),
|
||
}
|
||
import requests
|
||
resp = requests.post(
|
||
"https://ilinkai.weixin.qq.com/ilink/bot/getuploadurl",
|
||
headers=headers,
|
||
data=raw.encode('utf-8'),
|
||
timeout=15
|
||
)
|
||
if resp.status_code != 200:
|
||
return {"success": False, "error": f"申请上传参数失败,HTTP状态码: {resp.status_code}"}
|
||
result = resp.json()
|
||
upload_param = result.get("upload_param", "")
|
||
if not upload_param:
|
||
return {"success": False, "error": "响应中未包含upload_param"}
|
||
return {"success": True, "upload_param": upload_param}
|
||
|
||
def upload_to_cdn(upload_param: str, filekey: str, encrypted_data: bytes):
|
||
encoded_param = quote(upload_param, safe='')
|
||
cdn_url = f"https://novac2c.cdn.weixin.qq.com/c2c/upload?encrypted_query_param={encoded_param}&filekey={filekey}"
|
||
headers = {"Content-Type": "application/octet-stream"}
|
||
resp = httpx.post(cdn_url, headers=headers, content=encrypted_data, timeout=30)
|
||
if resp.status_code == 200:
|
||
encrypt_query_param = resp.headers.get('x-encrypted-param', '')
|
||
if not encrypt_query_param:
|
||
return {"success": False, "error": "未在响应头中找到x-encrypted-param"}
|
||
return {"success": True, "encrypt_query_param": encrypt_query_param}
|
||
else:
|
||
return {"success": False, "error": f"上传失败,HTTP状态码: {resp.status_code}"}
|
||
|
||
def send_weixin_image(bot_token: str, to_user_id: str, context_token: str, image_path: str) -> dict:
|
||
"""发送图片,需要提供正确的 context_token。返回 {"success": bool, "new_context_token": str}"""
|
||
prepare_result = prepare_image_upload(image_path)
|
||
if not prepare_result["success"]:
|
||
print(f"准备图片失败: {prepare_result.get('error')}")
|
||
return {"success": False, "new_context_token": ""}
|
||
|
||
filekey = prepare_result["filekey"]
|
||
aeskey_hex = prepare_result["aeskey_hex"]
|
||
rawsize = prepare_result["rawsize"]
|
||
rawfilemd5 = prepare_result["rawfilemd5"]
|
||
filesize = prepare_result["filesize"]
|
||
|
||
upload_params = get_upload_params(bot_token, filekey, 1, to_user_id,
|
||
rawsize, rawfilemd5, filesize, aeskey_hex)
|
||
if not upload_params["success"]:
|
||
print(f"申请上传失败: {upload_params.get('error')}")
|
||
return {"success": False, "new_context_token": ""}
|
||
|
||
encrypted_data = aes_encrypt_file(image_path, aeskey_hex)
|
||
upload_result = upload_to_cdn(upload_params["upload_param"], filekey, encrypted_data)
|
||
if not upload_result["success"]:
|
||
print(f"上传CDN失败: {upload_result.get('error')}")
|
||
return {"success": False, "new_context_token": ""}
|
||
encrypt_query_param = upload_result["encrypt_query_param"]
|
||
encoded_aes_key = encode_aes_key(aeskey_hex)
|
||
|
||
# 构造最终消息
|
||
timestamp_ms = int(time.time() * 1000)
|
||
random_suffix = uuid.uuid4().hex[:8]
|
||
client_id = f"weixin-bot:{timestamp_ms}-{random_suffix}"
|
||
random_uint32 = os.urandom(4)
|
||
x_wechat_uin = base64.b64encode(random_uint32).decode('utf-8')
|
||
|
||
payload = {
|
||
"msg": {
|
||
"from_user_id": "",
|
||
"to_user_id": to_user_id,
|
||
"client_id": client_id,
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"context_token": context_token,
|
||
"item_list": [
|
||
{
|
||
"type": 2,
|
||
"image_item": {
|
||
"media": {
|
||
"encrypt_query_param": encrypt_query_param,
|
||
"aes_key": encoded_aes_key,
|
||
"encrypt_type": 1
|
||
},
|
||
"mid_size": rawsize
|
||
}
|
||
}
|
||
]
|
||
},
|
||
"base_info": {"channel_version": "1.0.0"}
|
||
}
|
||
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"AuthorizationType": "ilink_bot_token",
|
||
"Authorization": f"Bearer {bot_token}",
|
||
"X-WECHAT-UIN": x_wechat_uin,
|
||
"Content-Length": str(len(raw.encode("utf-8"))),
|
||
}
|
||
import requests
|
||
resp = requests.post(
|
||
"https://ilinkai.weixin.qq.com/ilink/bot/sendmessage",
|
||
headers=headers,
|
||
data=raw.encode('utf-8'),
|
||
timeout=15
|
||
)
|
||
if resp.status_code == 200:
|
||
resp_json = resp.json()
|
||
# 调试:打印完整响应,确认 context_token 位置
|
||
print(f"[DEBUG] sendmessage 响应: {json.dumps(resp_json, ensure_ascii=False)[:500]}")
|
||
# 尝试从 msg.context_token 或顶层 context_token 获取
|
||
new_ctx = (
|
||
resp_json.get("msg", {}).get("context_token", "")
|
||
or resp_json.get("context_token", "")
|
||
)
|
||
ret = resp_json.get("ret")
|
||
if ret == 0 or not resp_json:
|
||
print(f"✅ 图片发送成功, new_ctx={'有' if new_ctx else '无'}")
|
||
return {"success": True, "new_context_token": new_ctx}
|
||
else:
|
||
print(f"❌ 发送失败,ret={ret}")
|
||
return {"success": False, "new_context_token": new_ctx}
|
||
else:
|
||
print(f"❌ HTTP错误 {resp.status_code}")
|
||
return {"success": False, "new_context_token": ""}
|
||
|
||
|
||
def send_weixin_text(bot_token: str, to_user_id: str, context_token: str, text: str) -> dict:
|
||
"""发送文字消息,返回 {"success": bool, "new_context_token": str}"""
|
||
timestamp_ms = int(time.time() * 1000)
|
||
random_suffix = uuid.uuid4().hex[:8]
|
||
client_id = f"weixin-bot:{timestamp_ms}-{random_suffix}"
|
||
random_uint32 = os.urandom(4)
|
||
x_wechat_uin = base64.b64encode(random_uint32).decode('utf-8')
|
||
|
||
payload = {
|
||
"msg": {
|
||
"from_user_id": "",
|
||
"to_user_id": to_user_id,
|
||
"client_id": client_id,
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"context_token": context_token,
|
||
"item_list": [
|
||
{
|
||
"type": 1,
|
||
"text_item": {"text": text}
|
||
}
|
||
]
|
||
},
|
||
"base_info": {"channel_version": "1.0.0"}
|
||
}
|
||
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"AuthorizationType": "ilink_bot_token",
|
||
"Authorization": f"Bearer {bot_token}",
|
||
"X-WECHAT-UIN": x_wechat_uin,
|
||
"Content-Length": str(len(raw.encode("utf-8"))),
|
||
}
|
||
import requests
|
||
resp = requests.post(
|
||
"https://ilinkai.weixin.qq.com/ilink/bot/sendmessage",
|
||
headers=headers,
|
||
data=raw.encode('utf-8'),
|
||
timeout=15
|
||
)
|
||
if resp.status_code == 200:
|
||
resp_json = resp.json()
|
||
new_ctx = (
|
||
resp_json.get("msg", {}).get("context_token", "")
|
||
or resp_json.get("context_token", "")
|
||
)
|
||
ret = resp_json.get("ret")
|
||
if ret == 0 or not resp_json:
|
||
print(f"✅ 文字发送成功")
|
||
return {"success": True, "new_context_token": new_ctx}
|
||
else:
|
||
print(f"❌ 文字发送失败,ret={ret}, errmsg={resp_json.get('errmsg', '')}")
|
||
return {"success": False, "new_context_token": new_ctx}
|
||
else:
|
||
print(f"❌ 文字HTTP错误 {resp.status_code}")
|
||
return {"success": False, "new_context_token": ""}
|
||
|
||
|
||
class weix:
|
||
def __init__(self):
|
||
self.bot = WeixinBot()
|
||
self.bot.login()
|
||
self.BOT_TOKEN = self.bot._credentials.token
|
||
self.MY_USER_ID = self.bot._credentials.user_id
|
||
self.bot.on_message(self.handle_message)
|
||
self.msg = ""
|
||
|
||
async def send_text(self, input_text):
|
||
# 不通过 bot.reply(),改用独立函数发送,避免 context_token 冲突
|
||
context_token = self.bot._context_tokens.get(self.msg.user_id)
|
||
result = await asyncio.to_thread(
|
||
send_weixin_text,
|
||
self.BOT_TOKEN,
|
||
self.msg.user_id,
|
||
context_token,
|
||
input_text
|
||
)
|
||
if result.get("new_context_token"):
|
||
self.bot._context_tokens[self.msg.user_id] = result["new_context_token"]
|
||
self.msg._context_token = result["new_context_token"]
|
||
return result["success"]
|
||
|
||
async def send_images(self, image_path):
|
||
# 发送图片,返回是否成功
|
||
context_token = self.bot._context_tokens.get(self.msg.user_id)
|
||
result = await asyncio.to_thread(
|
||
send_weixin_image,
|
||
self.BOT_TOKEN,
|
||
self.msg.user_id,
|
||
context_token,
|
||
image_path
|
||
)
|
||
# 用服务端返回的新 context_token 更新缓存,避免后续发送失败
|
||
if result.get("new_context_token"):
|
||
self.bot._context_tokens[self.msg.user_id] = result["new_context_token"]
|
||
self.msg._context_token = result["new_context_token"]
|
||
return result["success"]
|
||
|
||
async def send_video(self, video_path):
|
||
"""发送视频文件"""
|
||
context_token = self.bot._context_tokens.get(self.msg.user_id)
|
||
result = await asyncio.to_thread(
|
||
send_weixin_file,
|
||
self.BOT_TOKEN,
|
||
self.msg.user_id,
|
||
context_token,
|
||
video_path
|
||
)
|
||
return result
|
||
|
||
async def handle_message(self, msg):
|
||
print(f"收到消息: {msg.text} from {msg.user_id}")
|
||
self.msg = msg
|
||
|
||
|
||
def send_weixin_file(bot_token: str, to_user_id: str, context_token: str, file_path: str) -> bool:
|
||
"""
|
||
发送任意文件(图片、视频、音频、文档等)
|
||
返回 True/False
|
||
"""
|
||
# 判断文件类型
|
||
file_ext = Path(file_path).suffix.lower().lstrip('.')
|
||
image_exts = ['jpg','jpeg','png','gif','bmp','webp','tiff','svg']
|
||
video_exts = ['mp4','mov','avi','wmv','flv','mkv','webm','mpeg','mpg']
|
||
audio_exts = ['mp3','wav','aac','flac','m4a','ogg','wma']
|
||
doc_exts = ['pdf','doc','docx','xls','xlsx','ppt','pptx','txt']
|
||
archive_exts = ['zip','rar','7z','tar','gz']
|
||
|
||
if file_ext in image_exts:
|
||
media_type = 1
|
||
elif file_ext in video_exts:
|
||
media_type = 2
|
||
elif file_ext in audio_exts:
|
||
media_type = 4
|
||
elif file_ext in doc_exts or file_ext in archive_exts:
|
||
media_type = 3
|
||
else:
|
||
media_type = 3 # 默认当作普通文件
|
||
|
||
# 1. 准备上传参数
|
||
prepare_result = prepare_image_upload(file_path) # 复用之前的准备函数
|
||
if not prepare_result["success"]:
|
||
print(f"准备文件失败: {prepare_result.get('error')}")
|
||
return False
|
||
|
||
filekey = prepare_result["filekey"]
|
||
aeskey_hex = prepare_result["aeskey_hex"]
|
||
rawsize = prepare_result["rawsize"]
|
||
rawfilemd5 = prepare_result["rawfilemd5"]
|
||
filesize = prepare_result["filesize"]
|
||
|
||
# 2. 申请上传参数(媒体类型已确定)
|
||
upload_params = get_upload_params(bot_token, filekey, media_type, to_user_id,
|
||
rawsize, rawfilemd5, filesize, aeskey_hex)
|
||
if not upload_params["success"]:
|
||
print(f"申请上传失败: {upload_params.get('error')}")
|
||
return False
|
||
|
||
# 3. 加密文件
|
||
encrypted_data = aes_encrypt_file(file_path, aeskey_hex)
|
||
|
||
# 4. 上传到 CDN
|
||
upload_result = upload_to_cdn(upload_params["upload_param"], filekey, encrypted_data)
|
||
if not upload_result["success"]:
|
||
print(f"上传CDN失败: {upload_result.get('error')}")
|
||
return False
|
||
encrypt_query_param = upload_result["encrypt_query_param"]
|
||
encoded_aes_key = encode_aes_key(aeskey_hex)
|
||
|
||
# 5. 构造消息体(根据 media_type 不同)
|
||
# 公共字段
|
||
timestamp_ms = int(time.time() * 1000)
|
||
random_suffix = uuid.uuid4().hex[:8]
|
||
client_id = f"weixin-bot:{timestamp_ms}-{random_suffix}"
|
||
random_uint32 = os.urandom(4)
|
||
x_wechat_uin = base64.b64encode(random_uint32).decode('utf-8')
|
||
|
||
# 基础消息结构
|
||
msg_base = {
|
||
"msg": {
|
||
"from_user_id": "",
|
||
"to_user_id": to_user_id,
|
||
"client_id": client_id,
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"context_token": context_token,
|
||
"item_list": []
|
||
},
|
||
"base_info": {"channel_version": "1.0.0"}
|
||
}
|
||
|
||
# 根据类型填充 item
|
||
if media_type == 1: # 图片
|
||
item = {
|
||
"type": 2,
|
||
"image_item": {
|
||
"media": {
|
||
"encrypt_query_param": encrypt_query_param,
|
||
"aes_key": encoded_aes_key,
|
||
"encrypt_type": 1
|
||
},
|
||
"mid_size": rawsize
|
||
}
|
||
}
|
||
elif media_type == 2: # 视频
|
||
item = {
|
||
"type": 5,
|
||
"video_item": {
|
||
"media": {
|
||
"encrypt_query_param": encrypt_query_param,
|
||
"aes_key": encoded_aes_key,
|
||
"encrypt_type": 1
|
||
},
|
||
"video_size": rawsize # 明文大小
|
||
}
|
||
}
|
||
elif media_type == 3: # 普通文件
|
||
item = {
|
||
"type": 4,
|
||
"file_item": {
|
||
"media": {
|
||
"encrypt_query_param": encrypt_query_param,
|
||
"aes_key": encoded_aes_key,
|
||
"encrypt_type": 1
|
||
},
|
||
"file_name": Path(file_path).name
|
||
# 注意:main_send_file.py 中注释说 len 和 md5 写了可能发不出去,所以不加
|
||
}
|
||
}
|
||
elif media_type == 4: # 音频(通常也是文件类型,但微信可能支持语音,此处按文件处理)
|
||
# 微信音频消息类型可能是 3?文档不详,暂按文件处理
|
||
# 但为了兼容,我们可以也作为普通文件发送
|
||
item = {
|
||
"type": 4,
|
||
"file_item": {
|
||
"media": {
|
||
"encrypt_query_param": encrypt_query_param,
|
||
"aes_key": encoded_aes_key,
|
||
"encrypt_type": 1
|
||
},
|
||
"file_name": Path(file_path).name
|
||
}
|
||
}
|
||
else:
|
||
print(f"不支持的媒体类型: {media_type}")
|
||
return False
|
||
|
||
msg_base["msg"]["item_list"].append(item)
|
||
|
||
# 6. 发送请求
|
||
raw = json.dumps(msg_base, ensure_ascii=False)
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"AuthorizationType": "ilink_bot_token",
|
||
"Authorization": f"Bearer {bot_token}",
|
||
"X-WECHAT-UIN": x_wechat_uin,
|
||
"Content-Length": str(len(raw.encode("utf-8"))),
|
||
}
|
||
import requests
|
||
resp = requests.post(
|
||
"https://ilinkai.weixin.qq.com/ilink/bot/sendmessage",
|
||
headers=headers,
|
||
data=raw.encode('utf-8'),
|
||
timeout=15
|
||
)
|
||
if resp.status_code == 200:
|
||
resp_json = resp.json()
|
||
if resp_json.get('ret') == 0:
|
||
print(f"✅ {Path(file_path).name} 发送成功")
|
||
return True
|
||
else:
|
||
print(f"❌ 发送失败,ret={resp_json.get('ret')}")
|
||
return False
|
||
else:
|
||
print(f"❌ HTTP错误 {resp.status_code}")
|
||
return False |