mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
Expand image and edit support across messaging channels
This commit is contained in:
@@ -67,16 +67,40 @@ async def user_message(background_tasks: BackgroundTasks, request: Request,
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response)
|
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response)
|
||||||
def web_message(text: str, current_user: User = Depends(get_current_active_superuser)):
|
async def web_message(
|
||||||
|
request: Request,
|
||||||
|
text: Optional[str] = None,
|
||||||
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
WEB消息响应
|
WEB消息响应
|
||||||
"""
|
"""
|
||||||
|
images = None
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
text = payload.get("text", text)
|
||||||
|
image = payload.get("image")
|
||||||
|
images = payload.get("images")
|
||||||
|
if image:
|
||||||
|
if isinstance(images, list):
|
||||||
|
images = [*images, image]
|
||||||
|
else:
|
||||||
|
images = [image]
|
||||||
|
elif isinstance(images, str):
|
||||||
|
images = [images]
|
||||||
|
|
||||||
MessageChain().handle_message(
|
MessageChain().handle_message(
|
||||||
channel=MessageChannel.Web,
|
channel=MessageChannel.Web,
|
||||||
source=current_user.name,
|
source=current_user.name,
|
||||||
userid=current_user.name,
|
userid=current_user.name,
|
||||||
username=current_user.name,
|
username=current_user.name,
|
||||||
text=text
|
text=text or "",
|
||||||
|
images=images,
|
||||||
)
|
)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -1371,7 +1371,15 @@ class MessageChain(ChainBase):
|
|||||||
base64_images = []
|
base64_images = []
|
||||||
for img in images:
|
for img in images:
|
||||||
try:
|
try:
|
||||||
if img.startswith("tg://file_id/"):
|
if img.startswith("data:"):
|
||||||
|
base64_images.append(img)
|
||||||
|
logger.info(
|
||||||
|
"图片无需下载: channel=%s, source=%s, input=%s",
|
||||||
|
channel.value if channel else None,
|
||||||
|
source,
|
||||||
|
self._summarize_images([img])[0],
|
||||||
|
)
|
||||||
|
elif img.startswith("tg://file_id/"):
|
||||||
file_id = img.replace("tg://file_id/", "")
|
file_id = img.replace("tg://file_id/", "")
|
||||||
base64_data = self.run_module(
|
base64_data = self.run_module(
|
||||||
"download_file_to_base64", file_id=file_id, source=source
|
"download_file_to_base64", file_id=file_id, source=source
|
||||||
@@ -1384,6 +1392,23 @@ class MessageChain(ChainBase):
|
|||||||
source,
|
source,
|
||||||
img,
|
img,
|
||||||
)
|
)
|
||||||
|
elif img.startswith("wxwork://media_id/") or img.startswith(
|
||||||
|
"wxbot://image/"
|
||||||
|
):
|
||||||
|
data_url = self.run_module(
|
||||||
|
"download_wechat_image_to_data_url",
|
||||||
|
image_ref=img,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
if data_url:
|
||||||
|
base64_images.append(data_url)
|
||||||
|
logger.info(
|
||||||
|
"图片下载成功: channel=%s, source=%s, input=%s, output=%s",
|
||||||
|
channel.value if channel else None,
|
||||||
|
source,
|
||||||
|
img,
|
||||||
|
self._summarize_images([data_url])[0],
|
||||||
|
)
|
||||||
elif channel == MessageChannel.Slack:
|
elif channel == MessageChannel.Slack:
|
||||||
data_url = self.run_module(
|
data_url = self.run_module(
|
||||||
"download_file_to_data_url", file_url=img, source=source
|
"download_file_to_data_url", file_url=img, source=source
|
||||||
@@ -1397,6 +1422,21 @@ class MessageChain(ChainBase):
|
|||||||
img,
|
img,
|
||||||
self._summarize_images([data_url])[0],
|
self._summarize_images([data_url])[0],
|
||||||
)
|
)
|
||||||
|
elif img.startswith("vocechat://file/"):
|
||||||
|
data_url = self.run_module(
|
||||||
|
"download_vocechat_image_to_data_url",
|
||||||
|
image_ref=img,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
if data_url:
|
||||||
|
base64_images.append(data_url)
|
||||||
|
logger.info(
|
||||||
|
"图片下载成功: channel=%s, source=%s, input=%s, output=%s",
|
||||||
|
channel.value if channel else None,
|
||||||
|
source,
|
||||||
|
img,
|
||||||
|
self._summarize_images([data_url])[0],
|
||||||
|
)
|
||||||
elif img.startswith("http"):
|
elif img.startswith("http"):
|
||||||
resp = RequestUtils(timeout=30).get_res(img)
|
resp = RequestUtils(timeout=30).get_res(img)
|
||||||
if resp and resp.content:
|
if resp and resp.content:
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ except Exception as err: # ImportError or other load issues
|
|||||||
|
|
||||||
|
|
||||||
class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||||
|
_IMAGE_SUFFIXES = (
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".svg",
|
||||||
|
)
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
"""
|
"""
|
||||||
初始化模块
|
初始化模块
|
||||||
@@ -157,10 +168,17 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
return None
|
return None
|
||||||
images = []
|
images = []
|
||||||
for attachment in attachments:
|
for attachment in attachments:
|
||||||
if attachment.get("type") == "image":
|
url = attachment.get("url") or attachment.get("proxy_url")
|
||||||
url = attachment.get("url")
|
if not url:
|
||||||
if url:
|
continue
|
||||||
images.append(url)
|
content_type = (attachment.get("content_type") or "").lower()
|
||||||
|
filename = (attachment.get("filename") or "").lower()
|
||||||
|
if (
|
||||||
|
attachment.get("type") == "image"
|
||||||
|
or content_type.startswith("image/")
|
||||||
|
or filename.endswith(DiscordModule._IMAGE_SUFFIXES)
|
||||||
|
):
|
||||||
|
images.append(url)
|
||||||
return images if images else None
|
return images if images else None
|
||||||
|
|
||||||
def post_message(self, message: Notification, **kwargs) -> None:
|
def post_message(self, message: Notification, **kwargs) -> None:
|
||||||
@@ -363,15 +381,22 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
userid=userid,
|
userid=userid,
|
||||||
)
|
)
|
||||||
if result:
|
if result:
|
||||||
success, message_id = (
|
success, response_data = (
|
||||||
(result[0], result[1])
|
(result[0], result[1])
|
||||||
if isinstance(result, tuple)
|
if isinstance(result, tuple)
|
||||||
else (result, None)
|
else (result, None)
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
|
message_id = None
|
||||||
|
chat_id = None
|
||||||
|
if isinstance(response_data, dict):
|
||||||
|
message_id = response_data.get("message_id")
|
||||||
|
chat_id = response_data.get("chat_id")
|
||||||
|
elif response_data is not None:
|
||||||
|
message_id = str(response_data)
|
||||||
return MessageResponse(
|
return MessageResponse(
|
||||||
message_id=str(message_id) if message_id else None,
|
message_id=str(message_id) if message_id else None,
|
||||||
chat_id=None,
|
chat_id=str(chat_id) if chat_id else None,
|
||||||
channel=MessageChannel.Discord,
|
channel=MessageChannel.Discord,
|
||||||
source=conf.name,
|
source=conf.name,
|
||||||
success=True,
|
success=True,
|
||||||
|
|||||||
@@ -126,6 +126,20 @@ class Discord:
|
|||||||
if isinstance(message.channel, discord.DMChannel)
|
if isinstance(message.channel, discord.DMChannel)
|
||||||
else "guild",
|
else "guild",
|
||||||
}
|
}
|
||||||
|
if message.attachments:
|
||||||
|
payload["attachments"] = [
|
||||||
|
{
|
||||||
|
"id": str(attachment.id),
|
||||||
|
"filename": attachment.filename,
|
||||||
|
"content_type": attachment.content_type,
|
||||||
|
"url": attachment.url,
|
||||||
|
"proxy_url": attachment.proxy_url,
|
||||||
|
"size": attachment.size,
|
||||||
|
"height": attachment.height,
|
||||||
|
"width": attachment.width,
|
||||||
|
}
|
||||||
|
for attachment in message.attachments
|
||||||
|
]
|
||||||
await self._post_to_ds(payload)
|
await self._post_to_ds(payload)
|
||||||
|
|
||||||
@self._client.event
|
@self._client.event
|
||||||
@@ -346,7 +360,7 @@ class Discord:
|
|||||||
original_message_id: Optional[Union[int, str]],
|
original_message_id: Optional[Union[int, str]],
|
||||||
original_chat_id: Optional[str],
|
original_chat_id: Optional[str],
|
||||||
mtype: Optional["NotificationType"] = None,
|
mtype: Optional["NotificationType"] = None,
|
||||||
) -> Tuple[bool, Optional[int]]:
|
) -> Tuple[bool, Optional[Dict[str, str]]]:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}"
|
f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}"
|
||||||
)
|
)
|
||||||
@@ -373,13 +387,29 @@ class Discord:
|
|||||||
embed=embed,
|
embed=embed,
|
||||||
view=view,
|
view=view,
|
||||||
)
|
)
|
||||||
return success, int(original_message_id) if original_message_id else None
|
return (
|
||||||
|
success,
|
||||||
|
{
|
||||||
|
"message_id": str(original_message_id),
|
||||||
|
"chat_id": str(original_chat_id),
|
||||||
|
}
|
||||||
|
if success and original_message_id and original_chat_id
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(f"[Discord] 发送新消息到频道: {channel}")
|
logger.debug(f"[Discord] 发送新消息到频道: {channel}")
|
||||||
try:
|
try:
|
||||||
sent_message = await channel.send(content=content, embed=embed, view=view)
|
sent_message = await channel.send(content=content, embed=embed, view=view)
|
||||||
logger.debug("[Discord] 消息发送成功")
|
logger.debug("[Discord] 消息发送成功")
|
||||||
return True, sent_message.id if sent_message else None
|
return (
|
||||||
|
True,
|
||||||
|
{
|
||||||
|
"message_id": str(sent_message.id),
|
||||||
|
"chat_id": str(channel.id),
|
||||||
|
}
|
||||||
|
if sent_message and getattr(channel, "id", None) is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Discord] 发送消息到频道失败: {e}")
|
logger.error(f"[Discord] 发送消息到频道失败: {e}")
|
||||||
return False, None
|
return False, None
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ from app.schemas.types import ModuleType
|
|||||||
class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
||||||
"""QQ Bot 通知模块"""
|
"""QQ Bot 通知模块"""
|
||||||
|
|
||||||
|
_IMAGE_SUFFIXES = (
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".svg",
|
||||||
|
)
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
self.stop()
|
self.stop()
|
||||||
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
||||||
@@ -78,7 +89,8 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
|||||||
|
|
||||||
msg_type = msg_body.get("type")
|
msg_type = msg_body.get("type")
|
||||||
content = (msg_body.get("content") or "").strip()
|
content = (msg_body.get("content") or "").strip()
|
||||||
if not content:
|
images = self._extract_images(msg_body)
|
||||||
|
if not content and not images:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if msg_type == "C2C_MESSAGE_CREATE":
|
if msg_type == "C2C_MESSAGE_CREATE":
|
||||||
@@ -86,13 +98,17 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
|||||||
user_openid = author.get("user_openid", "")
|
user_openid = author.get("user_openid", "")
|
||||||
if not user_openid:
|
if not user_openid:
|
||||||
return None
|
return None
|
||||||
logger.info(f"收到 QQ 私聊消息: userid={user_openid}, text={content[:50]}...")
|
logger.info(
|
||||||
|
f"收到 QQ 私聊消息: userid={user_openid}, "
|
||||||
|
f"text={(content or '')[:50]}..., images={len(images) if images else 0}"
|
||||||
|
)
|
||||||
return CommingMessage(
|
return CommingMessage(
|
||||||
channel=MessageChannel.QQ,
|
channel=MessageChannel.QQ,
|
||||||
source=client_config.name,
|
source=client_config.name,
|
||||||
userid=user_openid,
|
userid=user_openid,
|
||||||
username=user_openid,
|
username=user_openid,
|
||||||
text=content,
|
text=content,
|
||||||
|
images=images,
|
||||||
)
|
)
|
||||||
elif msg_type == "GROUP_AT_MESSAGE_CREATE":
|
elif msg_type == "GROUP_AT_MESSAGE_CREATE":
|
||||||
author = msg_body.get("author", {})
|
author = msg_body.get("author", {})
|
||||||
@@ -100,16 +116,65 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
|||||||
group_openid = msg_body.get("group_openid", "")
|
group_openid = msg_body.get("group_openid", "")
|
||||||
# 群聊用 group:group_openid 作为 userid,便于回复时识别
|
# 群聊用 group:group_openid 作为 userid,便于回复时识别
|
||||||
userid = f"group:{group_openid}" if group_openid else member_openid
|
userid = f"group:{group_openid}" if group_openid else member_openid
|
||||||
logger.info(f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, text={content[:50]}...")
|
logger.info(
|
||||||
|
f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, "
|
||||||
|
f"text={(content or '')[:50]}..., images={len(images) if images else 0}"
|
||||||
|
)
|
||||||
return CommingMessage(
|
return CommingMessage(
|
||||||
channel=MessageChannel.QQ,
|
channel=MessageChannel.QQ,
|
||||||
source=client_config.name,
|
source=client_config.name,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=member_openid or group_openid,
|
username=member_openid or group_openid,
|
||||||
text=content,
|
text=content,
|
||||||
|
images=images,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_images(cls, msg_body: dict) -> Optional[List[str]]:
|
||||||
|
images: List[str] = []
|
||||||
|
attachments = msg_body.get("attachments") or []
|
||||||
|
if isinstance(attachments, list):
|
||||||
|
for attachment in attachments:
|
||||||
|
if not isinstance(attachment, dict):
|
||||||
|
continue
|
||||||
|
url = attachment.get("url") or attachment.get("proxy_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
content_type = (
|
||||||
|
attachment.get("content_type")
|
||||||
|
or attachment.get("mime_type")
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
filename = (
|
||||||
|
attachment.get("filename")
|
||||||
|
or attachment.get("name")
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
if content_type.startswith("image/") or filename.endswith(cls._IMAGE_SUFFIXES):
|
||||||
|
images.append(url)
|
||||||
|
|
||||||
|
for key in ("image", "image_url", "pic_url"):
|
||||||
|
value = msg_body.get(key)
|
||||||
|
if isinstance(value, str) and value.startswith("http"):
|
||||||
|
images.append(value)
|
||||||
|
|
||||||
|
extra_images = msg_body.get("images")
|
||||||
|
if isinstance(extra_images, list):
|
||||||
|
for item in extra_images:
|
||||||
|
if isinstance(item, str) and item.startswith("http"):
|
||||||
|
images.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
url = item.get("url") or item.get("image_url")
|
||||||
|
if isinstance(url, str) and url.startswith("http"):
|
||||||
|
images.append(url)
|
||||||
|
|
||||||
|
deduped = []
|
||||||
|
for image in images:
|
||||||
|
if image not in deduped:
|
||||||
|
deduped.append(image)
|
||||||
|
return deduped or None
|
||||||
|
|
||||||
def post_message(self, message: Notification, **kwargs) -> None:
|
def post_message(self, message: Notification, **kwargs) -> None:
|
||||||
for conf in self.get_configs().values():
|
for conf in self.get_configs().values():
|
||||||
if not self.check_message(message, conf.name):
|
if not self.check_message(message, conf.name):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Optional, Union, List, Tuple, Any
|
from typing import Optional, Union, List, Tuple, Any
|
||||||
|
import json
|
||||||
|
|
||||||
from app.core.context import MediaInfo, Context
|
from app.core.context import MediaInfo, Context
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -9,6 +10,16 @@ from app.schemas.types import ModuleType
|
|||||||
|
|
||||||
|
|
||||||
class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
|
class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
|
||||||
|
_IMAGE_SUFFIXES = (
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".svg",
|
||||||
|
)
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -96,15 +107,59 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
|
|||||||
user_id = int(message.get("user_id"))
|
user_id = int(message.get("user_id"))
|
||||||
# 获取用户名
|
# 获取用户名
|
||||||
user_name = message.get("username")
|
user_name = message.get("username")
|
||||||
if text and user_id:
|
images = self._extract_images(message)
|
||||||
logger.info(f"收到来自 {client_config.name} 的SynologyChat消息:"
|
if (text or images) and user_id:
|
||||||
f"userid={user_id}, username={user_name}, text={text}")
|
logger.info(
|
||||||
|
f"收到来自 {client_config.name} 的SynologyChat消息:"
|
||||||
|
f"userid={user_id}, username={user_name}, text={text}, images={len(images) if images else 0}"
|
||||||
|
)
|
||||||
return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name,
|
return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name,
|
||||||
userid=user_id, username=user_name, text=text)
|
userid=user_id, username=user_name, text=text or "",
|
||||||
|
images=images)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.debug(f"解析SynologyChat消息失败:{str(err)}")
|
logger.debug(f"解析SynologyChat消息失败:{str(err)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_images(cls, message: dict) -> Optional[List[str]]:
|
||||||
|
images = []
|
||||||
|
for key in ("file_url", "image_url", "pic_url"):
|
||||||
|
value = message.get(key)
|
||||||
|
if isinstance(value, str) and cls._looks_like_image(value):
|
||||||
|
images.append(value)
|
||||||
|
|
||||||
|
for key in ("attachments", "files"):
|
||||||
|
raw_value = message.get(key)
|
||||||
|
if not raw_value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
|
||||||
|
except Exception:
|
||||||
|
parsed = raw_value
|
||||||
|
items = parsed if isinstance(parsed, list) else [parsed]
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, str) and cls._looks_like_image(item):
|
||||||
|
images.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
url = item.get("url") or item.get("file_url") or item.get("image_url")
|
||||||
|
if isinstance(url, str) and cls._looks_like_image(url):
|
||||||
|
images.append(url)
|
||||||
|
|
||||||
|
deduped = []
|
||||||
|
for image in images:
|
||||||
|
if image not in deduped:
|
||||||
|
deduped.append(image)
|
||||||
|
return deduped or None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _looks_like_image(cls, value: str) -> bool:
|
||||||
|
if not value or not isinstance(value, str):
|
||||||
|
return False
|
||||||
|
lowered = value.lower()
|
||||||
|
return lowered.startswith("http") and any(
|
||||||
|
suffix in lowered for suffix in cls._IMAGE_SUFFIXES
|
||||||
|
)
|
||||||
|
|
||||||
def post_message(self, message: Notification, **kwargs) -> None:
|
def post_message(self, message: Notification, **kwargs) -> None:
|
||||||
"""
|
"""
|
||||||
发送消息
|
发送消息
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
from typing import Optional, Union, List, Tuple, Any, Dict
|
from typing import Optional, Union, List, Tuple, Any, Dict
|
||||||
|
|
||||||
from app.core.context import Context, MediaInfo
|
from app.core.context import Context, MediaInfo
|
||||||
@@ -10,6 +11,16 @@ from app.schemas.types import ModuleType
|
|||||||
|
|
||||||
|
|
||||||
class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
||||||
|
_IMAGE_SUFFIXES = (
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".svg",
|
||||||
|
)
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -99,12 +110,17 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
|||||||
msg_body = json.loads(body)
|
msg_body = json.loads(body)
|
||||||
# 类型
|
# 类型
|
||||||
msg_type = msg_body.get("detail", {}).get("type")
|
msg_type = msg_body.get("detail", {}).get("type")
|
||||||
if msg_type != "normal":
|
if msg_type not in ("normal", "reply"):
|
||||||
# 非新消息
|
# 非新消息/回复
|
||||||
return None
|
return None
|
||||||
logger.debug(f"收到VoceChat请求:{msg_body}")
|
logger.debug(f"收到VoceChat请求:{msg_body}")
|
||||||
# 文本内容
|
detail = msg_body.get("detail", {}) or {}
|
||||||
content = msg_body.get("detail", {}).get("content")
|
content_type = detail.get("content_type") or ""
|
||||||
|
content = detail.get("content")
|
||||||
|
images = self._extract_images(detail)
|
||||||
|
text = None
|
||||||
|
if content_type in ("text/plain", "text/markdown") and isinstance(content, str):
|
||||||
|
text = content
|
||||||
# 用户ID
|
# 用户ID
|
||||||
gid = msg_body.get("target", {}).get("gid")
|
gid = msg_body.get("target", {}).get("gid")
|
||||||
channel_id = client_config.config.get("channel_id")
|
channel_id = client_config.config.get("channel_id")
|
||||||
@@ -116,14 +132,56 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
|||||||
userid = f"UID#{msg_body.get('from_uid')}"
|
userid = f"UID#{msg_body.get('from_uid')}"
|
||||||
|
|
||||||
# 处理消息内容
|
# 处理消息内容
|
||||||
if content and userid:
|
if (text or images) and userid:
|
||||||
logger.info(f"收到来自 {client_config.name} 的VoceChat消息:userid={userid}, text={content}")
|
logger.info(
|
||||||
|
f"收到来自 {client_config.name} 的VoceChat消息:"
|
||||||
|
f"userid={userid}, text={text}, images={len(images) if images else 0}"
|
||||||
|
)
|
||||||
return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name,
|
return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name,
|
||||||
userid=userid, username=userid, text=content)
|
userid=userid, username=userid, text=text or "",
|
||||||
|
images=images)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"VoceChat消息处理发生错误:{str(err)}")
|
logger.error(f"VoceChat消息处理发生错误:{str(err)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_images(cls, detail: dict) -> Optional[List[str]]:
|
||||||
|
content_type = detail.get("content_type") or ""
|
||||||
|
if content_type != "vocechat/file":
|
||||||
|
return None
|
||||||
|
properties = detail.get("properties") or {}
|
||||||
|
mime_type = (
|
||||||
|
properties.get("content_type")
|
||||||
|
or properties.get("mime_type")
|
||||||
|
or properties.get("contentType")
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
file_path = (
|
||||||
|
properties.get("path")
|
||||||
|
or properties.get("file_path")
|
||||||
|
or properties.get("storage_path")
|
||||||
|
or detail.get("content")
|
||||||
|
)
|
||||||
|
direct_url = (
|
||||||
|
properties.get("url")
|
||||||
|
or properties.get("download_url")
|
||||||
|
or properties.get("file_url")
|
||||||
|
)
|
||||||
|
file_name = (
|
||||||
|
properties.get("name")
|
||||||
|
or properties.get("filename")
|
||||||
|
or (str(file_path).rsplit("/", 1)[-1] if file_path else "")
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
is_image = mime_type.startswith("image/") or file_name.endswith(cls._IMAGE_SUFFIXES)
|
||||||
|
if not is_image:
|
||||||
|
return None
|
||||||
|
if isinstance(direct_url, str) and direct_url.startswith("http"):
|
||||||
|
return [direct_url]
|
||||||
|
if isinstance(file_path, str) and file_path:
|
||||||
|
return [f"vocechat://file/{quote(file_path, safe='')}"]
|
||||||
|
return None
|
||||||
|
|
||||||
def post_message(self, message: Notification, **kwargs) -> None:
|
def post_message(self, message: Notification, **kwargs) -> None:
|
||||||
"""
|
"""
|
||||||
发送消息
|
发送消息
|
||||||
@@ -136,11 +194,11 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
|||||||
targets = message.targets
|
targets = message.targets
|
||||||
userid = message.userid
|
userid = message.userid
|
||||||
if not message.userid and targets:
|
if not message.userid and targets:
|
||||||
userid = targets.get('telegram_userid')
|
userid = targets.get('vocechat_userid')
|
||||||
client: VoceChat = self.get_instance(conf.name)
|
client: VoceChat = self.get_instance(conf.name)
|
||||||
if client:
|
if client:
|
||||||
client.send_msg(title=message.title, text=message.text,
|
client.send_msg(title=message.title, text=message.text,
|
||||||
userid=userid, link=message.link)
|
image=message.image, userid=userid, link=message.link)
|
||||||
|
|
||||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -182,3 +240,18 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
|||||||
|
|
||||||
def register_commands(self, commands: Dict[str, dict]):
|
def register_commands(self, commands: Dict[str, dict]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def download_vocechat_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
下载 VoceChat 图片并转换为 data URL
|
||||||
|
"""
|
||||||
|
if not image_ref or not image_ref.startswith("vocechat://file/"):
|
||||||
|
return None
|
||||||
|
client_config = self.get_config(source)
|
||||||
|
if not client_config:
|
||||||
|
return None
|
||||||
|
client: VoceChat = self.get_instance(client_config.name)
|
||||||
|
if not client:
|
||||||
|
return None
|
||||||
|
file_path = unquote(image_ref.replace("vocechat://file/", "", 1))
|
||||||
|
return client.download_file_to_data_url(file_path)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, List
|
import base64
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.core.context import MediaInfo, Context
|
from app.core.context import MediaInfo, Context
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
@@ -21,6 +23,7 @@ class VoceChat:
|
|||||||
_channel_id = None
|
_channel_id = None
|
||||||
# 请求对象
|
# 请求对象
|
||||||
_client = None
|
_client = None
|
||||||
|
_file_client = None
|
||||||
|
|
||||||
def __init__(self, VOCECHAT_HOST: Optional[str] = None, VOCECHAT_API_KEY: Optional[str] = None, VOCECHAT_CHANNEL_ID: Optional[str] = None, **kwargs):
|
def __init__(self, VOCECHAT_HOST: Optional[str] = None, VOCECHAT_API_KEY: Optional[str] = None, VOCECHAT_CHANNEL_ID: Optional[str] = None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -29,12 +32,11 @@ class VoceChat:
|
|||||||
if not VOCECHAT_HOST or not VOCECHAT_API_KEY or not VOCECHAT_CHANNEL_ID:
|
if not VOCECHAT_HOST or not VOCECHAT_API_KEY or not VOCECHAT_CHANNEL_ID:
|
||||||
logger.error("VoceChat配置不完整!")
|
logger.error("VoceChat配置不完整!")
|
||||||
return
|
return
|
||||||
self._host = VOCECHAT_HOST
|
self._host = VOCECHAT_HOST.strip()
|
||||||
if self._host:
|
if self._host and not self._host.startswith("http"):
|
||||||
if not self._host.endswith("/"):
|
self._host = f"http://{self._host}"
|
||||||
self._host += "/"
|
if self._host and not self._host.endswith("/"):
|
||||||
if not self._host.startswith("http"):
|
self._host += "/"
|
||||||
self._playhost = "http://" + self._host
|
|
||||||
self._apikey = VOCECHAT_API_KEY
|
self._apikey = VOCECHAT_API_KEY
|
||||||
self._channel_id = VOCECHAT_CHANNEL_ID
|
self._channel_id = VOCECHAT_CHANNEL_ID
|
||||||
if self._apikey and self._host and self._channel_id:
|
if self._apikey and self._host and self._channel_id:
|
||||||
@@ -43,6 +45,10 @@ class VoceChat:
|
|||||||
"x-api-key": self._apikey,
|
"x-api-key": self._apikey,
|
||||||
"accept": "application/json; charset=utf-8"
|
"accept": "application/json; charset=utf-8"
|
||||||
})
|
})
|
||||||
|
self._file_client = RequestUtils(headers={
|
||||||
|
"x-api-key": self._apikey,
|
||||||
|
"accept": "*/*"
|
||||||
|
})
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
"""
|
"""
|
||||||
@@ -61,6 +67,7 @@ class VoceChat:
|
|||||||
return result.json()
|
return result.json()
|
||||||
|
|
||||||
def send_msg(self, title: str, text: Optional[str] = None,
|
def send_msg(self, title: str, text: Optional[str] = None,
|
||||||
|
image: Optional[str] = None,
|
||||||
userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
|
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
|
||||||
@@ -83,6 +90,9 @@ class VoceChat:
|
|||||||
else:
|
else:
|
||||||
caption = f"**{title}**"
|
caption = f"**{title}**"
|
||||||
|
|
||||||
|
if image:
|
||||||
|
caption = f"{caption}\n"
|
||||||
|
|
||||||
if link:
|
if link:
|
||||||
caption = f"{caption}\n[查看详情]({link})"
|
caption = f"{caption}\n[查看详情]({link})"
|
||||||
|
|
||||||
@@ -97,6 +107,46 @@ class VoceChat:
|
|||||||
logger.error(f"发送消息失败:{msg_e}")
|
logger.error(f"发送消息失败:{msg_e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@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_file(self, path: str) -> Optional[Tuple[bytes, str]]:
|
||||||
|
"""
|
||||||
|
下载 VoceChat 文件资源
|
||||||
|
"""
|
||||||
|
if not path or not self._file_client:
|
||||||
|
return None
|
||||||
|
req_url = f"{self._host}api/resource/file?path={quote(path, safe='')}"
|
||||||
|
try:
|
||||||
|
res = self._file_client.get_res(req_url)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"VoceChat 文件下载失败:{err}")
|
||||||
|
return None
|
||||||
|
if not res or not res.content:
|
||||||
|
return None
|
||||||
|
mime_type = (res.headers.get("Content-Type") or "").split(";")[0].strip()
|
||||||
|
return res.content, mime_type or self._guess_mime_type(res.content)
|
||||||
|
|
||||||
|
def download_file_to_data_url(self, path: str) -> Optional[str]:
|
||||||
|
file_data = self.download_file(path)
|
||||||
|
if not file_data:
|
||||||
|
return None
|
||||||
|
content, mime_type = file_data
|
||||||
|
return f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
|
||||||
|
|
||||||
def send_medias_msg(self, title: str, medias: List[MediaInfo],
|
def send_medias_msg(self, title: str, medias: List[MediaInfo],
|
||||||
userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import xml.dom.minidom
|
import xml.dom.minidom
|
||||||
from typing import Optional, Union, List, Tuple, Any, Dict
|
from typing import Optional, Union, List, Tuple, Any, Dict
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
|
|||||||
if not client_config:
|
if not client_config:
|
||||||
return None
|
return None
|
||||||
if self._is_bot_mode(client_config.config):
|
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)
|
client: WeChat = self.get_instance(client_config.name)
|
||||||
# URL参数
|
# URL参数
|
||||||
sVerifyMsgSig = args.get("msg_signature")
|
sVerifyMsgSig = args.get("msg_signature")
|
||||||
@@ -163,6 +165,8 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
|
|||||||
logger.warn(f"解析不到消息类型和用户ID")
|
logger.warn(f"解析不到消息类型和用户ID")
|
||||||
return None
|
return None
|
||||||
# 解析消息内容
|
# 解析消息内容
|
||||||
|
content = None
|
||||||
|
images = None
|
||||||
if msg_type == "event" and event == "click":
|
if msg_type == "event" and event == "click":
|
||||||
# 校验用户有权限执行交互命令
|
# 校验用户有权限执行交互命令
|
||||||
if client_config.config.get('WECHAT_ADMINS'):
|
if client_config.config.get('WECHAT_ADMINS'):
|
||||||
@@ -178,17 +182,85 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
|
|||||||
# 文本消息
|
# 文本消息
|
||||||
content = DomUtils.tag_value(root_node, "Content", default="")
|
content = DomUtils.tag_value(root_node, "Content", default="")
|
||||||
logger.info(f"收到来自 {client_config.name} 的微信消息:userid={user_id}, text={content}")
|
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:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if content:
|
if content or images:
|
||||||
# 处理消息内容
|
# 处理消息内容
|
||||||
return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name,
|
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:
|
except Exception as err:
|
||||||
logger.error(f"微信消息处理发生错误:{str(err)}")
|
logger.error(f"微信消息处理发生错误:{str(err)}")
|
||||||
return None
|
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:
|
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,
|
client.send_msg(title=message.title, text=message.text,
|
||||||
image=message.image, userid=userid, link=message.link)
|
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:
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||||
"""
|
"""
|
||||||
发送媒体信息选择列表
|
发送媒体信息选择列表
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict
|
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}"
|
_create_menu_url = "cgi-bin/menu/create?access_token={access_token}&agentid={agentid}"
|
||||||
# 企业微信删除菜单URL
|
# 企业微信删除菜单URL
|
||||||
_delete_menu_url = "cgi-bin/menu/delete?access_token={access_token}&agentid={agentid}"
|
_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,
|
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):
|
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._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._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._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:
|
if self._corpid and self._appsecret and self._appid:
|
||||||
self.__get_access_token()
|
self.__get_access_token()
|
||||||
@@ -267,6 +271,58 @@ class WeChat:
|
|||||||
logger.error(f"发送消息失败:{e}")
|
logger.error(f"发送消息失败:{e}")
|
||||||
return False
|
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]:
|
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
发送列表类消息
|
发送列表类消息
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import base64
|
||||||
from typing import Optional, List, Dict, Tuple, Set
|
from typing import Optional, List, Dict, Tuple, Set
|
||||||
|
|
||||||
import websocket
|
import websocket
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
from app.core.cache import FileCache
|
from app.core.cache import FileCache
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -332,6 +334,116 @@ class WeChatBot:
|
|||||||
text = "\n".join(part for part in text_parts if part).strip()
|
text = "\n".join(part for part in text_parts if part).strip()
|
||||||
return text or None
|
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:
|
def _handle_callback_message(self, payload: dict) -> None:
|
||||||
body = payload.get("body") or {}
|
body = payload.get("body") or {}
|
||||||
sender = ((body.get("from") or {}).get("userid") or "").strip()
|
sender = ((body.get("from") or {}).get("userid") or "").strip()
|
||||||
@@ -343,20 +455,24 @@ class WeChatBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
text = self._extract_text_from_body(body)
|
text = self._extract_text_from_body(body)
|
||||||
if not text:
|
images = self._extract_images_from_body(body)
|
||||||
return
|
|
||||||
|
|
||||||
text = re.sub(r"@\S+", "", text).strip()
|
if text:
|
||||||
if not text:
|
text = re.sub(r"@\S+", "", text).strip()
|
||||||
|
|
||||||
|
if not text and not images:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._remember_target(sender)
|
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)
|
self.send_msg(title="只有管理员才有权限执行此命令", userid=sender)
|
||||||
return
|
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)
|
self._forward_to_message_chain(payload)
|
||||||
|
|
||||||
def _forward_to_message_chain(self, payload: dict) -> None:
|
def _forward_to_message_chain(self, payload: dict) -> None:
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ from telebot import apihelper
|
|||||||
from app.agent.tools.impl.send_message import SendMessageInput
|
from app.agent.tools.impl.send_message import SendMessageInput
|
||||||
from app.chain.message import MessageChain
|
from app.chain.message import MessageChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.modules.discord import DiscordModule
|
||||||
|
from app.modules.qqbot import QQBotModule
|
||||||
from app.modules.slack import SlackModule
|
from app.modules.slack import SlackModule
|
||||||
from app.modules.telegram.telegram import Telegram
|
from app.modules.telegram.telegram import Telegram
|
||||||
from app.modules.telegram import TelegramModule
|
from app.modules.telegram import TelegramModule
|
||||||
from app.schemas import CommingMessage
|
from app.modules.synologychat import SynologyChatModule
|
||||||
|
from app.modules.vocechat import VoceChatModule
|
||||||
|
from app.modules.wechat import WechatModule
|
||||||
|
from app.modules.wechat.wechatbot import WeChatBot
|
||||||
|
from app.schemas import CommingMessage, Notification
|
||||||
from app.schemas.types import MessageChannel
|
from app.schemas.types import MessageChannel
|
||||||
|
|
||||||
|
|
||||||
@@ -190,5 +196,281 @@ class AgentImageSupportTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(payload.image_url, "https://example.com/poster.png")
|
self.assertEqual(payload.image_url, "https://example.com/poster.png")
|
||||||
|
|
||||||
|
def test_discord_extract_images_supports_attachment_content_type(self):
|
||||||
|
images = DiscordModule._extract_images(
|
||||||
|
{
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"content_type": "image/png",
|
||||||
|
"url": "https://cdn.discordapp.com/test.png",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(images, ["https://cdn.discordapp.com/test.png"])
|
||||||
|
|
||||||
|
def test_discord_send_direct_message_returns_chat_id(self):
|
||||||
|
module = DiscordModule()
|
||||||
|
client = Mock()
|
||||||
|
client.send_msg.return_value = (
|
||||||
|
True,
|
||||||
|
{"message_id": "discord-msg-1", "chat_id": "discord-chat-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_configs",
|
||||||
|
return_value={"discord-test": SimpleNamespace(name="discord-test")},
|
||||||
|
), patch.object(
|
||||||
|
module, "check_message", return_value=True
|
||||||
|
), patch.object(
|
||||||
|
module, "get_instance", return_value=client
|
||||||
|
):
|
||||||
|
response = module.send_direct_message(
|
||||||
|
Notification(title="hi", userid="user-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(response)
|
||||||
|
self.assertEqual(response.message_id, "discord-msg-1")
|
||||||
|
self.assertEqual(response.chat_id, "discord-chat-1")
|
||||||
|
|
||||||
|
def test_download_images_routes_wechat_refs_to_module_downloader(self):
|
||||||
|
chain = MessageChain()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
chain,
|
||||||
|
"run_module",
|
||||||
|
return_value="data:image/png;base64,wechat123",
|
||||||
|
) as run_module:
|
||||||
|
images = chain._download_images_to_base64(
|
||||||
|
images=["wxwork://media_id/media-1"],
|
||||||
|
channel=MessageChannel.Wechat,
|
||||||
|
source="wechat-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(images, ["data:image/png;base64,wechat123"])
|
||||||
|
run_module.assert_called_once_with(
|
||||||
|
"download_wechat_image_to_data_url",
|
||||||
|
image_ref="wxwork://media_id/media-1",
|
||||||
|
source="wechat-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wechat_message_parser_extracts_image_media_id(self):
|
||||||
|
module = WechatModule()
|
||||||
|
xml_message = b"""
|
||||||
|
<xml>
|
||||||
|
<FromUserName><![CDATA[user-1]]></FromUserName>
|
||||||
|
<MsgType><![CDATA[image]]></MsgType>
|
||||||
|
<PicUrl><![CDATA[https://example.com/image.png]]></PicUrl>
|
||||||
|
<MediaId><![CDATA[media-1]]></MediaId>
|
||||||
|
</xml>
|
||||||
|
"""
|
||||||
|
crypt = Mock()
|
||||||
|
crypt.DecryptMsg.return_value = (0, xml_message)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_config",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
name="wechat-test",
|
||||||
|
config={
|
||||||
|
"WECHAT_TOKEN": "token",
|
||||||
|
"WECHAT_ENCODING_AESKEY": "encoding",
|
||||||
|
"WECHAT_CORPID": "corpid",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
), patch.object(
|
||||||
|
module, "get_instance", return_value=SimpleNamespace(send_msg=Mock())
|
||||||
|
), patch(
|
||||||
|
"app.modules.wechat.WXBizMsgCrypt",
|
||||||
|
return_value=crypt,
|
||||||
|
):
|
||||||
|
message = module.message_parser(
|
||||||
|
source="wechat-test",
|
||||||
|
body=b"encrypted",
|
||||||
|
form={},
|
||||||
|
args={"msg_signature": "sig", "timestamp": "1", "nonce": "n"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(message)
|
||||||
|
self.assertEqual(message.images, ["wxwork://media_id/media-1"])
|
||||||
|
|
||||||
|
def test_wechat_bot_parser_accepts_image_only_payload(self):
|
||||||
|
module = WechatModule()
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"from": {"userid": "wxbot-user"},
|
||||||
|
"msgtype": "image",
|
||||||
|
"image": {
|
||||||
|
"download_url": "https://example.com/encrypted-image",
|
||||||
|
"aeskey": "YWJjZGVmZw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_config",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
name="wechat-bot-test", config={"WECHAT_MODE": "bot"}
|
||||||
|
),
|
||||||
|
), patch.object(
|
||||||
|
module, "get_instance", return_value=SimpleNamespace(send_msg=Mock())
|
||||||
|
):
|
||||||
|
message = module.message_parser(
|
||||||
|
source="wechat-bot-test",
|
||||||
|
body=body,
|
||||||
|
form={},
|
||||||
|
args={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(message)
|
||||||
|
self.assertTrue(message.images[0].startswith("wxbot://image/"))
|
||||||
|
|
||||||
|
def test_wechat_bot_handles_image_only_callback(self):
|
||||||
|
bot = WeChatBot.__new__(WeChatBot)
|
||||||
|
bot._config_name = "wechat-bot-test"
|
||||||
|
bot._admins = []
|
||||||
|
bot.send_msg = Mock()
|
||||||
|
bot._remember_target = Mock()
|
||||||
|
bot._forward_to_message_chain = Mock()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"body": {
|
||||||
|
"from": {"userid": "wxbot-user"},
|
||||||
|
"msgtype": "image",
|
||||||
|
"image": {
|
||||||
|
"download_url": "https://example.com/encrypted-image",
|
||||||
|
"aeskey": "YWJjZGVmZw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot._handle_callback_message(payload)
|
||||||
|
|
||||||
|
bot._remember_target.assert_called_once_with("wxbot-user")
|
||||||
|
bot._forward_to_message_chain.assert_called_once_with(payload)
|
||||||
|
|
||||||
|
def test_vocechat_message_parser_extracts_image_file_payload(self):
|
||||||
|
module = VoceChatModule()
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"detail": {
|
||||||
|
"type": "normal",
|
||||||
|
"content_type": "vocechat/file",
|
||||||
|
"content": "/uploads/poster.png",
|
||||||
|
"properties": {"content_type": "image/png"},
|
||||||
|
},
|
||||||
|
"from_uid": 7910,
|
||||||
|
"target": {"gid": 2},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_config",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
name="vocechat-test", config={"channel_id": "2"}
|
||||||
|
),
|
||||||
|
):
|
||||||
|
message = module.message_parser(
|
||||||
|
source="vocechat-test",
|
||||||
|
body=body,
|
||||||
|
form={},
|
||||||
|
args={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(message)
|
||||||
|
self.assertEqual(
|
||||||
|
message.images,
|
||||||
|
["vocechat://file/%2Fuploads%2Fposter.png"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vocechat_post_message_passes_image_and_correct_target(self):
|
||||||
|
module = VoceChatModule()
|
||||||
|
client = Mock()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_configs",
|
||||||
|
return_value={"vocechat-test": SimpleNamespace(name="vocechat-test")},
|
||||||
|
), patch.object(
|
||||||
|
module, "check_message", return_value=True
|
||||||
|
), patch.object(
|
||||||
|
module, "get_instance", return_value=client
|
||||||
|
):
|
||||||
|
module.post_message(
|
||||||
|
Notification(
|
||||||
|
title="poster",
|
||||||
|
image="https://example.com/poster.png",
|
||||||
|
targets={"vocechat_userid": "UID#100"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.send_msg.assert_called_once_with(
|
||||||
|
title="poster",
|
||||||
|
text=None,
|
||||||
|
image="https://example.com/poster.png",
|
||||||
|
userid="UID#100",
|
||||||
|
link=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_qq_message_parser_accepts_image_only_attachment(self):
|
||||||
|
module = QQBotModule()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_config",
|
||||||
|
return_value=SimpleNamespace(name="qq-test", config={}),
|
||||||
|
):
|
||||||
|
message = module.message_parser(
|
||||||
|
source="qq-test",
|
||||||
|
body={
|
||||||
|
"type": "C2C_MESSAGE_CREATE",
|
||||||
|
"author": {"user_openid": "qq-user"},
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"content_type": "image/png",
|
||||||
|
"url": "https://example.com/qq-image.png",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
form={},
|
||||||
|
args={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(message)
|
||||||
|
self.assertEqual(message.images, ["https://example.com/qq-image.png"])
|
||||||
|
|
||||||
|
def test_synology_message_parser_accepts_image_only_form(self):
|
||||||
|
module = SynologyChatModule()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"get_config",
|
||||||
|
return_value=SimpleNamespace(name="synology-test", config={}),
|
||||||
|
), patch.object(
|
||||||
|
module,
|
||||||
|
"get_instance",
|
||||||
|
return_value=SimpleNamespace(check_token=lambda token: token == "token-1"),
|
||||||
|
):
|
||||||
|
message = module.message_parser(
|
||||||
|
source="synology-test",
|
||||||
|
body={},
|
||||||
|
form={
|
||||||
|
"token": "token-1",
|
||||||
|
"user_id": "42",
|
||||||
|
"username": "tester",
|
||||||
|
"file_url": "https://example.com/image.png",
|
||||||
|
},
|
||||||
|
args={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(message)
|
||||||
|
self.assertEqual(message.images, ["https://example.com/image.png"])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user