Expand image and edit support across messaging channels

This commit is contained in:
jxxghp
2026-04-11 22:10:54 +08:00
parent bf2d2cbd03
commit 2f53fd3108
12 changed files with 952 additions and 45 deletions

View File

@@ -1,4 +1,6 @@
import copy
import json
import re
import xml.dom.minidom
from typing import Optional, Union, List, Tuple, Any, Dict
@@ -103,7 +105,7 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
if not client_config:
return None
if self._is_bot_mode(client_config.config):
return None
return self._parse_bot_message(source=source, body=body, client_config=client_config)
client: WeChat = self.get_instance(client_config.name)
# URL参数
sVerifyMsgSig = args.get("msg_signature")
@@ -163,6 +165,8 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
logger.warn(f"解析不到消息类型和用户ID")
return None
# 解析消息内容
content = None
images = None
if msg_type == "event" and event == "click":
# 校验用户有权限执行交互命令
if client_config.config.get('WECHAT_ADMINS'):
@@ -178,17 +182,85 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
# 文本消息
content = DomUtils.tag_value(root_node, "Content", default="")
logger.info(f"收到来自 {client_config.name} 的微信消息userid={user_id}, text={content}")
elif msg_type == "image":
media_id = DomUtils.tag_value(root_node, "MediaId")
pic_url = DomUtils.tag_value(root_node, "PicUrl")
if media_id:
images = [f"wxwork://media_id/{media_id}"]
elif pic_url:
images = [pic_url]
logger.info(
f"收到来自 {client_config.name} 的微信图片消息userid={user_id}, images={len(images) if images else 0}"
)
else:
return None
if content:
if content or images:
# 处理消息内容
return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name,
userid=user_id, username=user_id, text=content)
userid=user_id, username=user_id, text=content or "",
images=images)
except Exception as err:
logger.error(f"微信消息处理发生错误:{str(err)}")
return None
def _parse_bot_message(self, source: str, body: Any, client_config) -> Optional[CommingMessage]:
try:
if isinstance(body, bytes):
msg_json = json.loads(body)
elif isinstance(body, dict):
msg_json = body
else:
msg_json = json.loads(body)
while isinstance(msg_json, str):
msg_json = json.loads(msg_json)
except Exception as err:
logger.debug(f"解析企业微信智能机器人消息失败:{err}")
return None
if not isinstance(msg_json, dict):
return None
payload_body = msg_json.get("body") or {}
sender = ((payload_body.get("from") or {}).get("userid") or "").strip()
if not sender:
return None
if payload_body.get("chattype") == "group":
return None
text = WeChatBot._extract_text_from_body(payload_body)
images = WeChatBot._extract_images_from_body(payload_body)
if text:
text = re.sub(r"@\S+", "", text).strip()
if text and text.startswith("/") and client_config.config.get('WECHAT_ADMINS'):
wechat_admins = [
admin.strip()
for admin in client_config.config.get('WECHAT_ADMINS', '').split(',')
if admin.strip()
]
if wechat_admins and sender not in wechat_admins:
client: WeChatBot = self.get_instance(client_config.name)
if client:
client.send_msg(title="只有管理员才有权限执行此命令", userid=sender)
return None
if not text and not images:
return None
logger.info(
f"收到来自 {client_config.name} 的企业微信智能机器人消息:"
f"userid={sender}, text={text}, images={len(images) if images else 0}"
)
return CommingMessage(
channel=MessageChannel.Wechat,
source=client_config.name,
userid=sender,
username=sender,
text=text or "",
images=images,
)
def post_message(self, message: Notification, **kwargs) -> None:
"""
发送消息
@@ -210,6 +282,25 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
client.send_msg(title=message.title, text=message.text,
image=message.image, userid=userid, link=message.link)
def download_wechat_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]:
"""
下载企业微信渠道图片并转换为 data URL
"""
if not image_ref:
return None
client_config = self.get_config(source)
if not client_config:
return None
client = self.get_instance(client_config.name)
if not client:
return None
if image_ref.startswith("wxwork://media_id/") and hasattr(client, "download_media_to_data_url"):
media_id = image_ref.replace("wxwork://media_id/", "", 1)
return client.download_media_to_data_url(media_id)
if image_ref.startswith("wxbot://image/") and hasattr(client, "download_image_to_data_url"):
return client.download_image_to_data_url(image_ref)
return None
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
"""
发送媒体信息选择列表

View File

@@ -1,6 +1,7 @@
import json
import re
import threading
import base64
from datetime import datetime
from typing import Optional, List, Dict
@@ -43,6 +44,8 @@ class WeChat:
_create_menu_url = "cgi-bin/menu/create?access_token={access_token}&agentid={agentid}"
# 企业微信删除菜单URL
_delete_menu_url = "cgi-bin/menu/delete?access_token={access_token}&agentid={agentid}"
# 企业微信下载媒体URL
_download_media_url = "cgi-bin/media/get?access_token={access_token}&media_id={media_id}"
def __init__(self, WECHAT_CORPID: Optional[str] = None, WECHAT_APP_SECRET: Optional[str] = None,
WECHAT_APP_ID: Optional[str] = None, WECHAT_PROXY: Optional[str] = None, **kwargs):
@@ -62,6 +65,7 @@ class WeChat:
self._token_url = UrlUtils.adapt_request_url(self._proxy, self._token_url)
self._create_menu_url = UrlUtils.adapt_request_url(self._proxy, self._create_menu_url)
self._delete_menu_url = UrlUtils.adapt_request_url(self._proxy, self._delete_menu_url)
self._download_media_url = UrlUtils.adapt_request_url(self._proxy, self._download_media_url)
if self._corpid and self._appsecret and self._appid:
self.__get_access_token()
@@ -267,6 +271,58 @@ class WeChat:
logger.error(f"发送消息失败:{e}")
return False
@staticmethod
def _guess_mime_type(content: bytes, default: str = "image/jpeg") -> str:
"""
根据文件头推断图片 MIME
"""
if not content:
return default
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if content.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if content.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if content.startswith(b"BM"):
return "image/bmp"
if content.startswith(b"RIFF") and b"WEBP" in content[:16]:
return "image/webp"
return default
def download_media_to_data_url(self, media_id: str) -> Optional[str]:
"""
下载企业微信媒体文件并转换为 data URL
"""
if not media_id:
return None
access_token = self.__get_access_token()
if not access_token:
logger.error("下载企业微信媒体失败access_token 获取失败")
return None
req_url = self._download_media_url.format(
access_token=access_token,
media_id=media_id,
)
try:
res = RequestUtils(timeout=30).get_res(req_url)
except Exception as err:
logger.error(f"下载企业微信媒体失败:{err}")
return None
if not res or not res.content:
return None
content_type = (res.headers.get("Content-Type") or "").split(";")[0].strip()
if content_type == "application/json":
try:
logger.error(f"企业微信媒体下载失败:{res.json()}")
except Exception:
logger.error(f"企业微信媒体下载失败:{res.text}")
return None
mime_type = self._guess_mime_type(res.content, content_type or "image/jpeg")
return f"data:{mime_type};base64,{base64.b64encode(res.content).decode()}"
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None) -> Optional[bool]:
"""
发送列表类消息

View File

@@ -5,9 +5,11 @@ import re
import threading
import time
import uuid
import base64
from typing import Optional, List, Dict, Tuple, Set
import websocket
from Crypto.Cipher import AES
from app.core.cache import FileCache
from app.core.config import settings
@@ -332,6 +334,116 @@ class WeChatBot:
text = "\n".join(part for part in text_parts if part).strip()
return text or None
@staticmethod
def _build_image_ref(image_payload: dict) -> Optional[str]:
if not image_payload or not isinstance(image_payload, dict):
return None
download_url = (
image_payload.get("download_url")
or image_payload.get("url")
or image_payload.get("cdnurl")
)
if not download_url:
return None
payload = {
"url": download_url,
"aeskey": image_payload.get("aeskey")
or image_payload.get("encoding_aes_key")
or image_payload.get("encrypt_key"),
"mime_type": image_payload.get("mime_type")
or image_payload.get("content_type"),
}
encoded = base64.urlsafe_b64encode(
json.dumps(payload, ensure_ascii=False).encode("utf-8")
).decode("ascii").rstrip("=")
return f"wxbot://image/{encoded}"
@classmethod
def _extract_images_from_body(cls, body: dict) -> Optional[List[str]]:
images: List[str] = []
msgtype = body.get("msgtype")
if msgtype == "image":
image_ref = cls._build_image_ref(body.get("image") or {})
if image_ref:
images.append(image_ref)
elif msgtype == "mixed":
for item in (body.get("mixed") or {}).get("msg_item") or []:
if item.get("msgtype") != "image":
continue
image_ref = cls._build_image_ref(item.get("image") or {})
if image_ref:
images.append(image_ref)
quote = body.get("quote") or {}
if not images and quote.get("msgtype") == "image":
image_ref = cls._build_image_ref(quote.get("image") or {})
if image_ref:
images.append(image_ref)
return images or None
@staticmethod
def _guess_mime_type(content: bytes, default: str = "image/jpeg") -> str:
if not content:
return default
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if content.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if content.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if content.startswith(b"BM"):
return "image/bmp"
if content.startswith(b"RIFF") and b"WEBP" in content[:16]:
return "image/webp"
return default
def download_image_to_data_url(self, image_ref: str) -> Optional[str]:
if not image_ref or not image_ref.startswith("wxbot://image/"):
return None
encoded = image_ref.replace("wxbot://image/", "", 1)
try:
padding = "=" * (-len(encoded) % 4)
payload = json.loads(
base64.urlsafe_b64decode((encoded + padding).encode("ascii")).decode(
"utf-8"
)
)
except Exception as err:
logger.error(f"解析企业微信智能机器人图片引用失败:{err}")
return None
download_url = payload.get("url")
if not download_url:
return None
try:
resp = RequestUtils(timeout=30).get_res(download_url)
except Exception as err:
logger.error(f"下载企业微信智能机器人图片失败:{err}")
return None
if not resp or not resp.content:
return None
content = resp.content
aes_key = payload.get("aeskey")
if aes_key:
try:
aes_bytes = base64.b64decode(aes_key + "=" * (-len(aes_key) % 4))
cipher = AES.new(aes_bytes, AES.MODE_CBC, aes_bytes[:16])
decrypted = cipher.decrypt(content)
padding_len = decrypted[-1]
if 0 < padding_len <= 32:
decrypted = decrypted[:-padding_len]
content = decrypted
except Exception as err:
logger.error(f"解密企业微信智能机器人图片失败:{err}")
return None
mime_type = self._guess_mime_type(content, payload.get("mime_type") or "image/jpeg")
return f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
def _handle_callback_message(self, payload: dict) -> None:
body = payload.get("body") or {}
sender = ((body.get("from") or {}).get("userid") or "").strip()
@@ -343,20 +455,24 @@ class WeChatBot:
return
text = self._extract_text_from_body(body)
if not text:
return
images = self._extract_images_from_body(body)
text = re.sub(r"@\S+", "", text).strip()
if not text:
if text:
text = re.sub(r"@\S+", "", text).strip()
if not text and not images:
return
self._remember_target(sender)
if text.startswith("/") and self._admins and sender not in self._admins:
if text and text.startswith("/") and self._admins and sender not in self._admins:
self.send_msg(title="只有管理员才有权限执行此命令", userid=sender)
return
logger.info(f"收到来自 {self._config_name} 的企业微信智能机器人消息userid={sender}, text={text}")
logger.info(
f"收到来自 {self._config_name} 的企业微信智能机器人消息:"
f"userid={sender}, text={text}, images={len(images) if images else 0}"
)
self._forward_to_message_chain(payload)
def _forward_to_message_chain(self, payload: dict) -> None: