From 9238ad58fff561b952e2be48e2ebf2d0a8bf81e4 Mon Sep 17 00:00:00 2001 From: tlw00988 <121912145+tlw00988@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:37:06 +0800 Subject: [PATCH 01/24] docs: corrent weixin_oc wechat version requirements (#7068) * docs:corrent weixin_oc wechat version requirements Update WeChat version requirements for the adapter. * Update docs/zh/platform/weixin_oc.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/zh/platform/weixin_oc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh/platform/weixin_oc.md b/docs/zh/platform/weixin_oc.md index 6ca7e92ce..7fc64dbb7 100644 --- a/docs/zh/platform/weixin_oc.md +++ b/docs/zh/platform/weixin_oc.md @@ -5,7 +5,7 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。 > [!NOTE] -> 需要升级到最新的手机微信版本:>= 8.0.70 +> 需要升级到最新的手机微信版本:iOS >= 8.0.70,Android >= 8.0.69,并确保微信中包含 ClawBot 插件 ## 支持的消息类型 From fcfd6a9e1cc46eeacfb0857c1a460e3424145540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=89=E7=9C=A0?= <33065020+Astral-Yang@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:45:58 +0800 Subject: [PATCH 02/24] fix(weixin_oc): allow CDN uploads to use `upload_full_url` when provided (#7066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(weixin_oc): 处理微信开放平台CDN上传URL的新格式 适配微信开放平台`getuploadurl`接口返回的新格式,该接口现在可能返回`upload_full_url`字段。 优先使用`upload_full_url`作为上传地址,以保持上传功能正常。 * fix(weixin_oc): 改进CDN上传URL缺失时的错误信息 - 在适配器中,将通用错误信息具体化为“CDN上传URL缺失” - 在客户端中,移除冗余的参数处理逻辑,使参数验证更清晰 * fix(weixin_oc): 调整CDN上传参数顺序并移除冗余检查 移除对weixin_oc_adapter中upload_param和upload_full_url同时为空的检查,因为逻辑上已由底层方法保证。 调整upload_to_cdn方法的参数顺序以匹配其内部实现,确保正确传递。 --- .../platform/sources/weixin_oc/weixin_oc_adapter.py | 4 ++-- .../platform/sources/weixin_oc/weixin_oc_client.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index 506979572..abb4c9599 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -593,10 +593,10 @@ class WeixinOCAdapter(Platform): len(str(payload.get("upload_param", ""))), ) upload_param = str(payload.get("upload_param", "")).strip() - if not upload_param: - raise RuntimeError("getuploadurl returned empty upload_param") + upload_full_url = str(payload.get("upload_full_url", "")).strip() encrypted_query_param = await self.client.upload_to_cdn( + upload_full_url, upload_param, file_key, aes_key_hex, diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py index 51b0b6ed7..1ea44df52 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py @@ -108,11 +108,21 @@ class WeixinOCClient: async def upload_to_cdn( self, + upload_full_url: str, upload_param: str, file_key: str, aes_key_hex: str, media_path: Path, ) -> str: + if upload_full_url: + cdn_url = upload_full_url + elif upload_param: + cdn_url = self._build_cdn_upload_url(upload_param, file_key) + else: + raise ValueError( + "CDN upload URL missing (need upload_full_url or upload_param)" + ) + raw_data = media_path.read_bytes() logger.debug( "weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s", @@ -135,7 +145,6 @@ class WeixinOCClient: await self.ensure_http_session() assert self._http_session is not None timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000) - cdn_url = self._build_cdn_upload_url(upload_param, file_key) async with self._http_session.post( cdn_url, From bcbf7dd8df0a7455a851e7a45a2f11d62b41c1e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Sat, 28 Mar 2026 21:31:52 +0900 Subject: [PATCH 03/24] fix: bundle httpx SOCKS proxy support (#7093) * fix: bundle httpx SOCKS proxy support * test: tighten SOCKS dependency regression coverage * test: allow SOCKS dependency environment markers * test: broaden SOCKS dependency regex coverage --- pyproject.toml | 1 + requirements.txt | 1 + tests/test_httpx_socks_dependency.py | 113 +++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 tests/test_httpx_socks_dependency.py diff --git a/pyproject.toml b/pyproject.toml index 2f12b7597..c129e8bbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "faiss-cpu>=1.12.0", "filelock>=3.18.0", "google-genai>=1.56.0", + "httpx[socks]>=0.28.1", "lark-oapi>=1.4.15", "lxml-html-clean>=0.4.2", "mcp>=1.8.0", diff --git a/requirements.txt b/requirements.txt index 124937039..838e4660e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ docstring-parser>=0.16 faiss-cpu>=1.12.0 filelock>=3.18.0 google-genai>=1.56.0 +httpx[socks]>=0.28.1 lark-oapi>=1.4.15 lxml-html-clean>=0.4.2 mcp>=1.8.0 diff --git a/tests/test_httpx_socks_dependency.py b/tests/test_httpx_socks_dependency.py new file mode 100644 index 000000000..499921253 --- /dev/null +++ b/tests/test_httpx_socks_dependency.py @@ -0,0 +1,113 @@ +import re +from pathlib import Path + +import pytest +import tomllib + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +REQUIREMENTS_PATH = PROJECT_ROOT / "requirements.txt" +PYPROJECT_PATH = PROJECT_ROOT / "pyproject.toml" +HTTPX_SOCKS_PATTERN = re.compile(r"^httpx\[socks\](?:\s*[<>=!~][^;]*)?(?:\s*;.*)?$") + + +def _read_httpx_socks_dependency(entries: list[str]) -> str | None: + for entry in entries: + candidate = entry.strip() + if HTTPX_SOCKS_PATTERN.match(candidate): + return candidate + return None + + +def _read_requirements() -> list[str]: + entries = [] + for line in REQUIREMENTS_PATH.read_text(encoding="utf-8").splitlines(): + candidate = line.split("#", 1)[0].strip() + if candidate: + entries.append(candidate) + return entries + + +def _read_pyproject_dependencies() -> list[str]: + with PYPROJECT_PATH.open("rb") as file: + pyproject = tomllib.load(file) + return pyproject["project"]["dependencies"] + + +def test_requirements_include_httpx_socks_dependency() -> None: + requirements_dependency = _read_httpx_socks_dependency(_read_requirements()) + + assert requirements_dependency is not None, ( + "Expected httpx[socks] dependency in requirements.txt for SOCKS proxy support" + ) + + +def test_pyproject_declares_httpx_socks_dependency() -> None: + pyproject_dependency = _read_httpx_socks_dependency(_read_pyproject_dependencies()) + + assert pyproject_dependency is not None, ( + "Expected httpx[socks] dependency in pyproject.toml for SOCKS proxy support" + ) + + +def test_httpx_socks_dependency_spec_matches_between_dependency_files() -> None: + requirements_dependency = _read_httpx_socks_dependency(_read_requirements()) + pyproject_dependency = _read_httpx_socks_dependency(_read_pyproject_dependencies()) + + assert requirements_dependency is not None, ( + "Expected httpx[socks] dependency in requirements.txt for SOCKS proxy support" + ) + assert pyproject_dependency is not None, ( + "Expected httpx[socks] dependency in pyproject.toml for SOCKS proxy support" + ) + assert requirements_dependency == pyproject_dependency, ( + "Expected httpx[socks] dependency spec to match between requirements.txt " + "and pyproject.toml for SOCKS proxy support" + ) + + +@pytest.mark.parametrize( + "entry", + [ + "httpx[socks]", + "httpx[socks]==0.27.0", + "httpx[socks]==0.28.1", + "httpx[socks]>=0.27.0,<0.28.0", + "httpx[socks]>=0.27,<0.29", + 'httpx[socks]; python_version >= "3.11"', + 'httpx[socks]>=0.27.0 ; python_version < "3.13"', + 'httpx[socks] ; python_version < "3.13"', + 'httpx[socks] >=0.27 ; python_version < "3.13"', + ], +) +def test_httpx_socks_pattern_matches_valid_variants(entry: str) -> None: + match = HTTPX_SOCKS_PATTERN.match(entry) + + assert match is not None, ( + f"Expected httpx[socks] dependency pattern to match valid entry for " + f"SOCKS proxy support: {entry}" + ) + assert match.group(0) == entry, ( + f"Expected httpx[socks] dependency pattern to fully match valid entry " + f"for SOCKS proxy support: {entry}" + ) + + +@pytest.mark.parametrize( + "entry", + [ + "httpx", + "httpx==0.27.0", + "httpx[http2]", + "httpx[socks-extra]", + "httpx [socks]", + "someprefix httpx[socks]", + "httpx[socks] trailing-text", + "httpx[socks] extra ; markers", + "httpx[socks]andmore", + ], +) +def test_httpx_socks_pattern_rejects_invalid_variants(entry: str) -> None: + assert HTTPX_SOCKS_PATTERN.match(entry) is None, ( + f"Expected httpx[socks] dependency pattern to reject invalid entry for " + f"SOCKS proxy support: {entry}" + ) From 995a3182322f3cd58ba6001baed73aa6fd0d7c90 Mon Sep 17 00:00:00 2001 From: Rain-0x01_ <83620631+Rain-0x01-39@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:48:43 +0800 Subject: [PATCH 04/24] fix(gsvi_tts): Use the correct calling method (#7083) * fix(gsvi_tts): Use the correct calling method (#5638) Add some configuration items for GSVI * fix(gsvi_tts): add default value for api_key in provider configuration * fix(gsvi_tts): Adjust wherever the Authorization header is built to only include it when `self.api_key` is truthy Delete some comments * chore: ruff format --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/config/default.py | 10 +++- .../core/provider/sources/gsvi_tts_source.py | 57 ++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 246914ee0..49def4a57 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1623,10 +1623,14 @@ CONFIG_METADATA_2 = { "type": "gsvi_tts_api", "provider": "gpt_sovits_inference", "provider_type": "text_to_speech", - "api_base": "http://127.0.0.1:5000", - "character": "", - "emotion": "default", "enable": False, + "api_key": "", + "api_base": "http://127.0.0.1:8000", + "version": "v4", + "character": "", + "prompt_text_lang": "中文", + "emotion": "默认", + "text_lang": "中文", "timeout": 20, }, "FishAudio TTS(API)": { diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 425e801f4..55a0975de 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -1,6 +1,5 @@ -import os -import urllib.parse import uuid +from pathlib import Path import aiohttp @@ -23,37 +22,55 @@ class ProviderGSVITTS(TTSProvider): provider_settings: dict, ) -> None: super().__init__(provider_config, provider_settings) - self.api_base = provider_config.get("api_base", "http://127.0.0.1:5000") + self.api_key = provider_config.get("api_key", "") + self.api_base = provider_config.get("api_base", "http://127.0.0.1:8000") self.api_base = self.api_base.removesuffix("/") + self.version = provider_config.get("version", "v4") self.character = provider_config.get("character") - self.emotion = provider_config.get("emotion") + self.prompt_text_lang = provider_config.get("prompt_text_lang", "中文") + self.emotion = provider_config.get("emotion", "默认") + self.text_lang = provider_config.get("text_lang", "中文") async def get_audio(self, text: str) -> str: temp_dir = get_astrbot_temp_path() - path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav") - params = {"text": text} + path = Path(temp_dir) / f"gsvi_tts_{uuid.uuid4()}.wav" + url = f"{self.api_base}/infer_single" - if self.character: - params["character"] = self.character - if self.emotion: - params["emotion"] = self.emotion + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" - query_parts = [] - for key, value in params.items(): - encoded_value = urllib.parse.quote(str(value)) - query_parts.append(f"{key}={encoded_value}") - - url = f"{self.api_base}/tts?{'&'.join(query_parts)}" + data = { + "dl_url": self.api_base, + "version": self.version, + "model_name": self.character, + "prompt_text_lang": self.prompt_text_lang, + "emotion": self.emotion, + "text": text, + "text_lang": self.text_lang, + } async with aiohttp.ClientSession() as session: - async with session.get(url) as response: + async with session.post(url, json=data, headers=headers) as response: if response.status == 200: - with open(path, "wb") as f: - f.write(await response.read()) + resp_json = await response.json() + msg = resp_json.get("msg") + audio_url = resp_json.get("audio_url") + if not msg or msg != "合成成功": + raise Exception(f"GSVI TTS API 合成失败: {msg}") + async with session.get(audio_url) as audio_response: + if audio_response.status == 200: + with open(path, "wb") as f: + f.write(await audio_response.read()) + else: + error_text = await audio_response.text() + raise Exception( + f"GSVI TTS API 下载音频失败,状态码: {audio_response.status},错误: {error_text}", + ) else: error_text = await response.text() raise Exception( f"GSVI TTS API 请求失败,状态码: {response.status},错误: {error_text}", ) - return path + return str(path) From 4e9916caa4da29fbc9bead8004ad01a50c7fc200 Mon Sep 17 00:00:00 2001 From: Stardust Date: Sat, 28 Mar 2026 20:50:42 +0800 Subject: [PATCH 05/24] fix(pipeline): skip waking on empty messages (#6893) * fix(pipeline): skip waking on empty messages * fix(pipeline): skip empty message LLM request even with provider_request * fix(pipeline): skip empty messages in empty_mention_waiter --- astrbot/builtin_stars/session_controller/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/astrbot/builtin_stars/session_controller/main.py b/astrbot/builtin_stars/session_controller/main.py index 70081e03a..8ce28fa23 100644 --- a/astrbot/builtin_stars/session_controller/main.py +++ b/astrbot/builtin_stars/session_controller/main.py @@ -91,6 +91,8 @@ class Main(Star): controller: SessionController, event: AstrMessageEvent, ) -> None: + if not event.message_str or not event.message_str.strip(): + return event.message_obj.message.insert( 0, Comp.At(qq=event.get_self_id(), name=event.get_self_id()), From 81f4bd4e67fb1cb2096e2ff9d9b30e25c5d4da43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=8B=E6=9C=88=E7=99=BD?= <105275068+jmt059@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:16:40 +0800 Subject: [PATCH 06/24] fix: allow multiple skills in a single zip archive (#7070) * fix: allow multiple skills in a single zip archive * refactor: address bot review comments * fix: apply ruff format and fix return value --- astrbot/core/skills/skill_manager.py | 165 +++++++++++++++++---------- 1 file changed, 107 insertions(+), 58 deletions(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 1b71e5371..a8121c42a 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -548,6 +548,8 @@ class SkillManager: if not zipfile.is_zipfile(zip_path): raise ValueError("Uploaded file is not a valid zip archive.") + installed_skills = [] + with zipfile.ZipFile(zip_path) as zf: names = [ name @@ -573,34 +575,6 @@ class SkillManager: ): raise ValueError("Invalid skill name.") - if root_mode: - archive_hint = _normalize_skill_name( - archive_skill_name or zip_path_obj.stem - ) - if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint): - raise ValueError("Invalid skill name.") - skill_name = archive_hint - else: - top_dirs = { - PurePosixPath(name).parts[0] for name in file_names if name.strip() - } - if len(top_dirs) != 1: - raise ValueError( - "Zip archive must contain a single top-level folder." - ) - archive_root_name = next(iter(top_dirs)) - archive_root_name_normalized = _normalize_skill_name(archive_root_name) - if archive_root_name in {".", "..", ""} or not _SKILL_NAME_RE.fullmatch( - archive_root_name_normalized - ): - raise ValueError("Invalid skill folder name.") - if archive_skill_name: - if not _SKILL_NAME_RE.fullmatch(archive_skill_name): - raise ValueError("Invalid skill name.") - skill_name = archive_skill_name - else: - skill_name = archive_root_name_normalized - for name in names: if not name: continue @@ -609,20 +583,38 @@ class SkillManager: parts = PurePosixPath(name).parts if ".." in parts: raise ValueError("Zip archive contains invalid relative paths.") - if (not root_mode) and parts and parts[0] != archive_root_name: - raise ValueError( - "Zip archive contains unexpected top-level entries." - ) - if root_mode: - if "SKILL.md" not in file_names and "skill.md" not in file_names: - raise ValueError("SKILL.md not found in the skill folder.") - else: - if ( - f"{archive_root_name}/SKILL.md" not in file_names - and f"{archive_root_name}/skill.md" not in file_names - ): - raise ValueError("SKILL.md not found in the skill folder.") + if not root_mode and not overwrite: + top_dirs = {PurePosixPath(n).parts[0] for n in file_names if n.strip()} + conflict_dirs: list[str] = [] + for src_dir_name in top_dirs: + if ( + f"{src_dir_name}/SKILL.md" not in file_names + and f"{src_dir_name}/skill.md" not in file_names + ): + continue + + candidate_name = _normalize_skill_name(src_dir_name) + if not candidate_name or not _SKILL_NAME_RE.fullmatch( + candidate_name + ): + continue + + if archive_skill_name and len(top_dirs) == 1: + target_name = archive_skill_name + else: + target_name = candidate_name + + dest_dir = Path(self.skills_root) / target_name + if dest_dir.exists(): + conflict_dirs.append(str(dest_dir)) + + if conflict_dirs: + raise FileExistsError( + "One or more skills from the archive already exist and " + "overwrite=False. No skills were installed. Conflicting " + f"paths: {', '.join(conflict_dirs)}" + ) with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir: for member in zf.infolist(): @@ -630,21 +622,78 @@ class SkillManager: if not member_name or _is_ignored_zip_entry(member_name): continue zf.extract(member, tmp_dir) - src_dir = ( - Path(tmp_dir) if root_mode else Path(tmp_dir) / archive_root_name - ) - normalized_path = _normalize_skill_markdown_path(src_dir) - if normalized_path is None: - raise ValueError("SKILL.md not found in the skill folder.") - _normalize_skill_markdown_path(src_dir) - if not src_dir.exists(): - raise ValueError("Skill folder not found after extraction.") - dest_dir = Path(self.skills_root) / skill_name - if dest_dir.exists(): - if not overwrite: - raise FileExistsError("Skill already exists.") - shutil.rmtree(dest_dir) - shutil.move(str(src_dir), str(dest_dir)) - self.set_skill_active(skill_name, True) - return skill_name + if root_mode: + archive_hint = _normalize_skill_name( + archive_skill_name or zip_path_obj.stem + ) + if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint): + raise ValueError("Invalid skill name.") + skill_name = archive_hint + + src_dir = Path(tmp_dir) + normalized_path = _normalize_skill_markdown_path(src_dir) + if normalized_path is None: + raise ValueError( + "SKILL.md not found in the root of the zip archive." + ) + + dest_dir = Path(self.skills_root) / skill_name + if dest_dir.exists() and overwrite: + shutil.rmtree(dest_dir) + elif dest_dir.exists() and not overwrite: + raise FileExistsError(f"Skill {skill_name} already exists.") + + shutil.move(str(src_dir), str(dest_dir)) + self.set_skill_active(skill_name, True) + installed_skills.append(skill_name) + + else: + top_dirs = { + PurePosixPath(n).parts[0] for n in file_names if n.strip() + } + + for archive_root_name in top_dirs: + archive_root_name_normalized = _normalize_skill_name( + archive_root_name + ) + + if ( + f"{archive_root_name}/SKILL.md" not in file_names + and f"{archive_root_name}/skill.md" not in file_names + ): + continue + + if archive_root_name in {".", "..", ""} or not ( + _SKILL_NAME_RE.fullmatch(archive_root_name_normalized) + ): + continue + + if archive_skill_name and len(top_dirs) == 1: + skill_name = archive_skill_name + else: + skill_name = archive_root_name_normalized + + src_dir = Path(tmp_dir) / archive_root_name + normalized_path = _normalize_skill_markdown_path(src_dir) + if normalized_path is None: + continue + + dest_dir = Path(self.skills_root) / skill_name + if dest_dir.exists(): + if not overwrite: + raise FileExistsError( + f"Skill {skill_name} already exists." + ) + shutil.rmtree(dest_dir) + + shutil.move(str(src_dir), str(dest_dir)) + self.set_skill_active(skill_name, True) + installed_skills.append(skill_name) + + if not installed_skills: + raise ValueError( + "No valid SKILL.md found in any folder of the zip archive." + ) + + return ", ".join(installed_skills) From b98bd3898fc3cc3987d9445cff16bab73353bc57 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:18:33 +0800 Subject: [PATCH 07/24] feat: update QQOfficialPlatformAdapter to support async parsing and attachment preparation (#7007) fixes: #6853 --- .../core/pipeline/preprocess_stage/stage.py | 4 +- .../qqofficial/qqofficial_platform_adapter.py | 60 +++++++++++++++---- .../qqofficial_webhook/qo_webhook_adapter.py | 8 +-- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/astrbot/core/pipeline/preprocess_stage/stage.py b/astrbot/core/pipeline/preprocess_stage/stage.py index 464f584f8..0d6d09370 100644 --- a/astrbot/core/pipeline/preprocess_stage/stage.py +++ b/astrbot/core/pipeline/preprocess_stage/stage.py @@ -76,8 +76,8 @@ class PreProcessStage(Stage): return message_chain = event.get_messages() for idx, component in enumerate(message_chain): - if isinstance(component, Record) and component.url: - path = component.url.removeprefix("file://") + if isinstance(component, Record): + path = await component.convert_to_file_path() retry = 5 for i in range(retry): try: diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 82a6afbac..8419c0ea0 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -5,6 +5,8 @@ import logging import os import random import time +import uuid +from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -24,6 +26,8 @@ from astrbot.api.platform import ( ) from astrbot.core.message.components import BaseMessageComponent from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.io import download_file from ...register import register_platform_adapter from .qqofficial_message_event import QQOfficialMessageEvent @@ -42,7 +46,7 @@ class botClient(Client): async def on_group_at_message_create( self, message: botpy.message.GroupMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -53,7 +57,7 @@ class botClient(Client): # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -66,7 +70,7 @@ class botClient(Client): async def on_direct_message_create( self, message: botpy.message.DirectMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) @@ -76,7 +80,7 @@ class botClient(Client): # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) @@ -336,7 +340,22 @@ class QQOfficialPlatformAdapter(Platform): return f"https://{url}" @staticmethod - def _append_attachments( + async def _prepare_audio_attachment( + url: str, + filename: str, + ) -> Record: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + + ext = Path(filename).suffix.lower() + source_ext = ext or ".audio" + source_path = temp_dir / f"qqofficial_{uuid.uuid4().hex}{source_ext}" + await download_file(url, str(source_path)) + + return Record(file=str(source_path), url=str(source_path)) + + @staticmethod + async def _append_attachments( msg: list[BaseMessageComponent], attachments: list | None, ) -> None: @@ -363,7 +382,7 @@ class QQOfficialPlatformAdapter(Platform): or getattr(attachment, "name", None) or "attachment", ) - ext = os.path.splitext(filename)[1].lower() + ext = Path(filename).suffix.lower() image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} audio_exts = { ".mp3", @@ -381,8 +400,21 @@ class QQOfficialPlatformAdapter(Platform): ".webm", } - if content_type.startswith("audio") or ext in audio_exts: - msg.append(Record.fromURL(url)) + if content_type.startswith("voice") or ext in audio_exts: + try: + msg.append( + await QQOfficialPlatformAdapter._prepare_audio_attachment( + url, + filename, + ) + ) + except Exception as e: + logger.warning( + "[QQOfficial] Failed to prepare audio attachment %s: %s", + url, + e, + ) + msg.append(Record.fromURL(url)) elif content_type.startswith("video") or ext in video_exts: msg.append(Video.fromURL(url)) elif content_type.startswith("image") or ext in image_exts: @@ -432,13 +464,13 @@ class QQOfficialPlatformAdapter(Platform): return re.sub(r"]*>", replace_face, content) @staticmethod - def _parse_from_qqofficial( + async def _parse_from_qqofficial( message: botpy.message.Message | botpy.message.GroupMessage | botpy.message.DirectMessage | botpy.message.C2CMessage, message_type: MessageType, - ): + ) -> AstrBotMessage: abm = AstrBotMessage() abm.type = message_type abm.timestamp = int(time.time()) @@ -463,7 +495,9 @@ class QQOfficialPlatformAdapter(Platform): abm.self_id = "unknown_selfid" msg.append(At(qq="qq_official")) msg.append(Plain(abm.message_str)) - QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) + await QQOfficialPlatformAdapter._append_attachments( + msg, message.attachments + ) abm.message = msg elif isinstance(message, botpy.message.Message) or isinstance( @@ -482,7 +516,9 @@ class QQOfficialPlatformAdapter(Platform): ).strip() ) - QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) + await QQOfficialPlatformAdapter._append_attachments( + msg, message.attachments + ) abm.message = msg abm.message_str = plain_content abm.sender = MessageMember( diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index 4c73fdf38..b4dbe36b0 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -31,7 +31,7 @@ class botClient(Client): async def on_group_at_message_create( self, message: botpy.message.GroupMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -42,7 +42,7 @@ class botClient(Client): # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -55,7 +55,7 @@ class botClient(Client): async def on_direct_message_create( self, message: botpy.message.DirectMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) @@ -65,7 +65,7 @@ class botClient(Client): # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) From bbec8efa0d790fb99209b82d2706299192a0ebad Mon Sep 17 00:00:00 2001 From: Foolllll <62875591+Foolllll-J@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:22:00 +0800 Subject: [PATCH 08/24] fix(dashboard): apply labels mapping for list options in config renderer (#6844) --- dashboard/src/components/shared/ConfigItemRenderer.vue | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 3c3262064..edae891e1 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -86,11 +86,13 @@ > - + > Date: Sat, 28 Mar 2026 21:25:19 +0800 Subject: [PATCH 09/24] feat: allow copy config from existing configs (#6785) * feat: allow copy config from existing configs * fix: issues mentioned by reviewer bot - duplicated logic for initializing/resetting the config - check whitespace-only names --- .../i18n/locales/en-US/features/config.json | 3 + .../i18n/locales/ru-RU/features/config.json | 5 +- .../i18n/locales/zh-CN/features/config.json | 3 + dashboard/src/views/ConfigPage.vue | 148 ++++++++++++++---- 4 files changed, 126 insertions(+), 33 deletions(-) diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 4b726ae3c..f0b19156b 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -77,13 +77,16 @@ "title": "Configuration Management", "description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.", "newConfig": "New Configuration", + "copyConfig": "Copy Configuration", "editConfig": "Edit Configuration", "manageConfigs": "Manage Configurations...", "configName": "Name", "fillConfigName": "Enter configuration name", "confirmDelete": "Are you sure you want to delete the configuration \"{name}\"? This action cannot be undone.", "pleaseEnterName": "Please enter a configuration name", + "nameExists": "Configuration name already exists. Please use another name.", "createFailed": "Failed to create new configuration", + "copyFailed": "Failed to copy configuration", "deleteFailed": "Failed to delete configuration", "updateFailed": "Failed to update configuration" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config.json b/dashboard/src/i18n/locales/ru-RU/features/config.json index 7c456ab9d..555b321d3 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config.json @@ -77,13 +77,16 @@ "title": "Управление конфигурациями", "description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».", "newConfig": "Новая конфигурация", + "copyConfig": "Копировать конфигурацию", "editConfig": "Изменить конфигурацию", "manageConfigs": "Управление файлами...", "configName": "Имя", "fillConfigName": "Введите имя конфигурации", "confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.", "pleaseEnterName": "Пожалуйста, введите имя", + "nameExists": "Имя конфигурации уже существует. Используйте другое имя.", "createFailed": "Ошибка создания конфигурации", + "copyFailed": "Ошибка копирования конфигурации", "deleteFailed": "Ошибка удаления", "updateFailed": "Ошибка обновления" }, @@ -126,4 +129,4 @@ "cancel": "Отмена" } } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index e7cd90408..3575d05b7 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -77,13 +77,16 @@ "title": "配置文件管理", "description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。", "newConfig": "新建配置文件", + "copyConfig": "复制配置文件", "editConfig": "编辑配置文件", "manageConfigs": "管理配置文件...", "configName": "名称", "fillConfigName": "填写配置文件名称", "confirmDelete": "确定要删除配置文件 \"{name}\" 吗?此操作不可恢复。", "pleaseEnterName": "请填写配置名称", + "nameExists": "配置文件名称已存在,请使用其他名称", "createFailed": "新配置文件创建失败", + "copyFailed": "复制配置文件失败", "deleteFailed": "删除配置文件失败", "updateFailed": "更新配置文件失败" }, diff --git a/dashboard/src/views/ConfigPage.vue b/dashboard/src/views/ConfigPage.vue index 0ecc2a41d..cae432bf1 100644 --- a/dashboard/src/views/ConfigPage.vue +++ b/dashboard/src/views/ConfigPage.vue @@ -126,11 +126,15 @@ -