Initial commit: WeChat bot project with AI chat, video export, and image upload scripts

This commit is contained in:
anqiang12
2026-06-18 15:53:24 +08:00
commit 3b1e106cb0
16 changed files with 2541 additions and 0 deletions

476
weixin_sender.py Normal file
View File

@@ -0,0 +1,476 @@
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