diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index a30afcd8f2..82c0a8f4f3 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -1,6 +1,7 @@ import asyncio import os import sys +import time import uuid from collections.abc import Awaitable, Callable from typing import Any, cast @@ -140,6 +141,8 @@ async def shutdown_trigger(self) -> None: @register_platform_adapter("wecom", "wecom 适配器", support_streaming_message=False) class WecomPlatformAdapter(Platform): + WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS = 15 + def __init__( self, platform_config: dict, @@ -166,6 +169,7 @@ def __init__( self.server = WecomServer(self._event_queue, self.config) self.agent_id: str | None = None + self._wechat_kf_seen_text_messages: dict[str, float] = {} self.client = WeChatClient( self.config["corpid"].strip(), @@ -210,6 +214,28 @@ def get_latest_msg_item() -> dict | None: self.server.callback = callback + def _is_duplicate_wechat_kf_text_message(self, session_id: str, text: str) -> bool: + normalized_text = text.strip() + if not normalized_text: + return False + + now = time.monotonic() + expired_keys = [ + key + for key, expires_at in self._wechat_kf_seen_text_messages.items() + if expires_at <= now + ] + for key in expired_keys: + self._wechat_kf_seen_text_messages.pop(key, None) + + dedup_key = f"{session_id}:{normalized_text}" + if dedup_key in self._wechat_kf_seen_text_messages: + return True + self._wechat_kf_seen_text_messages[dedup_key] = ( + now + self.WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS + ) + return False + @override async def send_by_session( self, @@ -390,6 +416,13 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: abm.message_str = "" if msgtype == "text": text = msg.get("text", {}).get("content", "").strip() + if self._is_duplicate_wechat_kf_text_message(abm.session_id, text): + logger.debug( + "忽略 15 秒内重复微信客服文本消息 session_id=%s text=%s", + abm.session_id, + text, + ) + return None abm.message = [Plain(text=text)] abm.message_str = text elif msgtype == "image": diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 60c21d9496..f15f28dec9 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -43,7 +43,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter from astrbot.core.computer.file_read_utils import read_file_tool_result -from astrbot.core.message.components import File +from astrbot.core.message.components import File, Image from astrbot.core.utils.astrbot_path import ( get_astrbot_skills_path, get_astrbot_system_tmp_path, @@ -64,6 +64,7 @@ _SANDBOX_RUNTIME_TOOL_CONFIG = { "provider_settings.computer_use_runtime": "sandbox", } +_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"} def _restricted_env_path_labels(umo: str) -> list[str]: @@ -729,11 +730,21 @@ async def call( if also_send_to_user: try: name = os.path.basename(local_path) + if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES: + message_component = Image.fromFileSystem(local_path) + sent_as = "image" + else: + message_component = File(name=name, file=local_path) + sent_as = "file" await context.context.event.send( - MessageChain(chain=[File(name=name, file=local_path)]) + MessageChain(chain=[message_component]) ) except Exception as e: logger.error(f"Error sending file message: {e}") + return ( + f"File downloaded successfully to {local_path} " + f"but sending to user failed: {e}" + ) # remove # try: @@ -741,7 +752,10 @@ async def call( # except Exception as e: # logger.error(f"Error removing temp file {local_path}: {e}") - return f"File downloaded successfully to {local_path} and sent to user." + return ( + f"File downloaded successfully to {local_path} " + f"and sent to user as {sent_as}." + ) return f"File downloaded successfully to {local_path}" except Exception as e: diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 047529396e..0bf6b820e0 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -5,8 +5,9 @@ import httpx from astrbot import logger +from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi -_SYSTEM_SSL_CTX = ssl.create_default_context() +_SYSTEM_SSL_CTX = build_ssl_context_with_certifi() def is_connection_error(exc: BaseException) -> bool: @@ -92,9 +93,9 @@ def create_proxy_client( ) -> httpx.AsyncClient: """Create an httpx AsyncClient with proxy configuration if provided. - Uses the system SSL certificate store instead of certifi, which avoids - SSL verification failures for endpoints whose CA chain is not in certifi - but is trusted by the operating system. + Uses a hybrid SSL context that combines the system SSL certificate store + with certifi as a fallback, ensuring compatibility across different + environments including Windows where the system store may be incomplete. Note: The caller is responsible for closing the client when done. Consider using the client as a context manager or calling aclose() explicitly. @@ -103,11 +104,11 @@ def create_proxy_client( provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini") proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty headers: Optional custom headers to include in every request - verify: Optional override for TLS verification. Defaults to the shared - system SSL context when not provided. + verify: Optional override for TLS verification. Defaults to the hybrid + SSL context (system store + certifi) when not provided. Returns: - An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided. + An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided. """ resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify if proxy: