From 34cf4014e6bcc15c5f8c8f70d379b25a2b0f57f9 Mon Sep 17 00:00:00 2001 From: XiaoYang <91766792+3351163616@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:48:13 +0800 Subject: [PATCH 01/14] fix: prevent numeric input from resetting to zero on blur without edit (#7560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a numeric input field was focused but not edited, the blur handler called toNumber(null) which returned 0 via parseFloat(null) → NaN → 0. Now we skip emitting the update when numericTemp is null (no edits made). --- dashboard/src/components/shared/ConfigItemRenderer.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 0cdddcde6..58e41f3ab 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -161,7 +161,7 @@ Date: Thu, 16 Apr 2026 14:51:36 +0800 Subject: [PATCH 02/14] fix: increase anthropic default max tokens (#7593) --- astrbot/core/provider/sources/anthropic_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 83f2e16db..764457759 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -293,7 +293,7 @@ class ProviderAnthropic(Provider): extra_body = self.provider_config.get("custom_extra_body", {}) if "max_tokens" not in payloads: - payloads["max_tokens"] = 1024 + payloads["max_tokens"] = 65536 self._apply_thinking_config(payloads) try: @@ -389,7 +389,7 @@ class ProviderAnthropic(Provider): reasoning_signature = "" if "max_tokens" not in payloads: - payloads["max_tokens"] = 1024 + payloads["max_tokens"] = 65536 self._apply_thinking_config(payloads) async with self.client.messages.stream( From a8660ff21efff4fe9c31b482f7c1c6c1e47cb16d Mon Sep 17 00:00:00 2001 From: SweetenedSuzuka Date: Thu, 16 Apr 2026 20:03:59 +0800 Subject: [PATCH 03/14] fix(weixin_oc): persist context_token for proactive cron sends (#7595) * fix(weixin_oc): persist context_token for proactive cron sends * test(weixin_oc): add safety coverage for context token persistence * fix(weixin_oc): address review on context token state * chore: delete tests/unit/test_weixin_oc_adapter_state.py --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> --- .../sources/weixin_oc/weixin_oc_adapter.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 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 f70cc556e..e332474d2 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -7,6 +7,7 @@ import io import time import uuid from collections import deque +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -149,6 +150,7 @@ class WeixinOCAdapter(Platform): self._sync_buf = "" self._qr_expired_count = 0 self._context_tokens: dict[str, str] = {} + self._context_tokens_dirty = False self._typing_states: dict[str, TypingSessionState] = {} self._last_inbound_error = "" self._recent_message_cache_size = self._get_int_config( @@ -538,12 +540,29 @@ class WeixinOCAdapter(Platform): saved_base = str(self.config.get("weixin_oc_base_url", "")).strip() if saved_base: self.base_url = saved_base.rstrip("/") + raw_context_tokens = self.config.get("weixin_oc_context_tokens", {}) + if isinstance(raw_context_tokens, dict): + self._context_tokens = self._normalize_context_tokens(raw_context_tokens) + + def _normalize_context_tokens( + self, raw_context_tokens: Mapping[object, object] + ) -> dict[str, str]: + normalized_context_tokens: dict[str, str] = {} + for user_id, context_token in raw_context_tokens.items(): + normalized_user_id = str(user_id).strip() + normalized_context_token = str(context_token).strip() + if not normalized_user_id or not normalized_context_token: + continue + normalized_context_tokens[normalized_user_id] = normalized_context_token + return normalized_context_tokens async def _save_account_state(self) -> None: + normalized_context_tokens = self._normalize_context_tokens(self._context_tokens) self.config["weixin_oc_token"] = self.token or "" self.config["weixin_oc_account_id"] = self.account_id or "" self.config["weixin_oc_sync_buf"] = self._sync_buf self.config["weixin_oc_base_url"] = self.base_url + self.config["weixin_oc_context_tokens"] = normalized_context_tokens for platform in astrbot_config.get("platform", []): if not isinstance(platform, dict): @@ -556,10 +575,12 @@ class WeixinOCAdapter(Platform): platform["weixin_oc_account_id"] = self.account_id or "" platform["weixin_oc_sync_buf"] = self._sync_buf platform["weixin_oc_base_url"] = self.base_url + platform["weixin_oc_context_tokens"] = normalized_context_tokens break self._sync_client_state() astrbot_config.save_config() + self._context_tokens_dirty = False def _is_login_session_valid( self, login_session: OpenClawLoginSession | None @@ -1445,7 +1466,10 @@ class WeixinOCAdapter(Platform): context_token = str(msg.get("context_token", "")).strip() if context_token: - self._context_tokens[from_user_id] = context_token + previous_context_token = self._context_tokens.get(from_user_id) + if previous_context_token != context_token: + self._context_tokens[from_user_id] = context_token + self._context_tokens_dirty = True item_list = cast(list[dict[str, Any]], msg.get("item_list", [])) reply_component = None @@ -1539,9 +1563,10 @@ class WeixinOCAdapter(Platform): ) return + should_save_state = self._context_tokens_dirty if data.get("get_updates_buf"): self._sync_buf = str(data.get("get_updates_buf")) - await self._save_account_state() + should_save_state = True for msg in data.get("msgs", []) if isinstance(data.get("msgs"), list) else []: if self._shutdown_event.is_set(): @@ -1549,6 +1574,8 @@ class WeixinOCAdapter(Platform): if not isinstance(msg, dict): continue await self._handle_inbound_message(msg) + if should_save_state: + await self._save_account_state() def _message_chain_to_text(self, message_chain: MessageChain) -> str: text = "" From ee85a4e50f389d47782c3da0c0210947df04141a Mon Sep 17 00:00:00 2001 From: Kangyang Ji Date: Thu, 16 Apr 2026 13:05:37 +0100 Subject: [PATCH 04/14] fix: scss import warning (#7528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(dashboard): 将 Sass @import 迁移到 @use Dart Sass 3.0.0 将移除 @import,迁移到 @use 以消除弃用警告 * add new import into style.scss --------- Co-authored-by: LIghtJUNction --- dashboard/src/scss/components/_VButtons.scss | 2 ++ dashboard/src/scss/components/_VCard.scss | 2 ++ dashboard/src/scss/components/_VField.scss | 2 ++ dashboard/src/scss/components/_VShadow.scss | 2 ++ dashboard/src/scss/components/_VTabs.scss | 2 ++ dashboard/src/scss/layout/_container.scss | 2 ++ dashboard/src/scss/layout/_sidebar.scss | 2 ++ dashboard/src/scss/style.scss | 32 ++++++++++---------- 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/dashboard/src/scss/components/_VButtons.scss b/dashboard/src/scss/components/_VButtons.scss index a7d095e95..fce0668f8 100644 --- a/dashboard/src/scss/components/_VButtons.scss +++ b/dashboard/src/scss/components/_VButtons.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + // // Light Buttons // diff --git a/dashboard/src/scss/components/_VCard.scss b/dashboard/src/scss/components/_VCard.scss index 10dcaaf86..abdd4dd5f 100644 --- a/dashboard/src/scss/components/_VCard.scss +++ b/dashboard/src/scss/components/_VCard.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + // Outline Card .v-card--variant-outlined { border-color: rgba(var(--v-theme-borderLight), 0.36); diff --git a/dashboard/src/scss/components/_VField.scss b/dashboard/src/scss/components/_VField.scss index 97352acf5..2798d0b44 100644 --- a/dashboard/src/scss/components/_VField.scss +++ b/dashboard/src/scss/components/_VField.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + .v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr, .v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start { border-radius: $border-radius-root 0 0 $border-radius-root; diff --git a/dashboard/src/scss/components/_VShadow.scss b/dashboard/src/scss/components/_VShadow.scss index aebe0e8e4..3a42f4459 100644 --- a/dashboard/src/scss/components/_VShadow.scss +++ b/dashboard/src/scss/components/_VShadow.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + .elevation-10 { box-shadow: $box-shadow !important; } diff --git a/dashboard/src/scss/components/_VTabs.scss b/dashboard/src/scss/components/_VTabs.scss index afb08fd46..6112051b8 100644 --- a/dashboard/src/scss/components/_VTabs.scss +++ b/dashboard/src/scss/components/_VTabs.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + .theme-tab { &.v-tabs { .v-tab { diff --git a/dashboard/src/scss/layout/_container.scss b/dashboard/src/scss/layout/_container.scss index f1ca9294f..8f095211a 100644 --- a/dashboard/src/scss/layout/_container.scss +++ b/dashboard/src/scss/layout/_container.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + html { overflow-y: auto; } diff --git a/dashboard/src/scss/layout/_sidebar.scss b/dashboard/src/scss/layout/_sidebar.scss index 52f9e404a..58472befd 100644 --- a/dashboard/src/scss/layout/_sidebar.scss +++ b/dashboard/src/scss/layout/_sidebar.scss @@ -1,3 +1,5 @@ +@use '../variables' as *; + /*This is for the logo*/ .leftSidebar { border: 0px; diff --git a/dashboard/src/scss/style.scss b/dashboard/src/scss/style.scss index 6e02010ba..f77e38f32 100644 --- a/dashboard/src/scss/style.scss +++ b/dashboard/src/scss/style.scss @@ -1,21 +1,21 @@ -@import './variables'; -@import 'vuetify/styles/main.sass'; -@import './override'; -@import './layout/container'; -@import './layout/sidebar'; +@use './variables'; +@use 'vuetify/styles/main.sass'; +@use './override'; +@use './layout/container'; +@use './layout/sidebar'; -@import './components/VButtons'; -@import './components/VCard'; -@import './components/VField'; -@import './components/VInput'; -@import './components/VNavigationDrawer'; -@import './components/VShadow'; -@import './components/VTextField'; -@import './components/VTabs'; -@import './components/VScrollbar'; -@import './components/CodeBlockDark'; +@use './components/VButtons'; +@use './components/VCard'; +@use './components/VField'; +@use './components/VInput'; +@use './components/VNavigationDrawer'; +@use './components/VShadow'; +@use './components/VTextField'; +@use './components/VTabs'; +@use './components/VScrollbar'; +@use './components/CodeBlockDark'; -@import './pages/dashboards'; +@use './pages/dashboards'; html, body { overscroll-behavior-y: none; From 22e8cbd10da8737975aad5fc9234bc855be9388a Mon Sep 17 00:00:00 2001 From: Hongbro886 Date: Thu, 16 Apr 2026 20:14:55 +0800 Subject: [PATCH 05/14] fix: return an explicit erro from the cron tool when scheduling a task fails instead of processing silently(#7513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 定时任务创建失败时返回错误信息而非静默处理 * fix: test and format --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/cron/manager.py | 16 +++++++++++++--- astrbot/core/tools/cron_tools.py | 25 ++++++++++++++++--------- tests/unit/test_cron_manager.py | 21 +++++++++++---------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index c86fc160f..5b2c6ee9e 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -22,6 +22,12 @@ if TYPE_CHECKING: from astrbot.core.star.context import Context +class CronJobSchedulingError(Exception): + """Raised when a cron job fails to be scheduled.""" + + pass + + class CronJobManager: """Central scheduler for BasicCronJob and ActiveAgentCronJob.""" @@ -59,7 +65,10 @@ class CronJobManager: job.job_id, ) continue - self._schedule_job(job) + try: + self._schedule_job(job) + except CronJobSchedulingError: + continue # Error already logged in _schedule_job async def add_basic_job( self, @@ -181,8 +190,9 @@ class CronJobManager: job.job_id, next_run_time=self._get_next_run_time(job.job_id) ) ) - except Exception as e: - logger.error(f"Failed to schedule cron job {job.job_id}: {e!s}") + except (ValueError, TypeError) as e: + logger.exception("Failed to schedule cron job %s", job.job_id) + raise CronJobSchedulingError(str(e)) from e def _get_next_run_time(self, job_id: str): aps_job = self.scheduler.get_job(job_id) diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index d0b563816..bbfa5729d 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -7,6 +7,7 @@ from pydantic.dataclasses import dataclass from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.cron.manager import CronJobSchedulingError from astrbot.core.tools.registry import builtin_tool _CRON_TOOL_CONFIG = { @@ -112,14 +113,17 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]): "origin": "tool", } - job = await cron_mgr.add_active_job( - name=name, - cron_expression=str(cron_expression) if cron_expression else None, - payload=payload, - description=note, - run_once=run_once, - run_at=run_at_dt, - ) + try: + job = await cron_mgr.add_active_job( + name=name, + cron_expression=str(cron_expression) if cron_expression else None, + payload=payload, + description=note, + run_once=run_once, + run_at=run_at_dt, + ) + except CronJobSchedulingError: + return "error: failed to schedule task due to invalid configuration." next_run = job.next_run_time or run_at_dt suffix = ( f"one-time at {next_run}" @@ -195,7 +199,10 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]): updates["cron_expression"] = cron_expression updates["payload"] = payload - job = await cron_mgr.update_job(str(job_id), **updates) + try: + job = await cron_mgr.update_job(str(job_id), **updates) + except CronJobSchedulingError: + return "error: failed to update task due to invalid configuration." if not job: return f"error: cron job {job_id} not found." return f"Updated future task {job.job_id} ({job.name})." diff --git a/tests/unit/test_cron_manager.py b/tests/unit/test_cron_manager.py index b111384ac..9dd3fc34d 100644 --- a/tests/unit/test_cron_manager.py +++ b/tests/unit/test_cron_manager.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from astrbot.core.cron.manager import CronJobManager +from astrbot.core.cron.manager import CronJobManager, CronJobSchedulingError from astrbot.core.db.po import CronJob @@ -190,24 +190,25 @@ class TestAddActiveJob: @pytest.mark.asyncio async def test_add_active_job_run_once(self, cron_manager, mock_db, sample_cron_job): - """Test adding a run-once active job.""" + """Test adding a run-once active job with an invalid returned job.""" sample_cron_job.job_type = "active_agent" sample_cron_job.run_once = True mock_db.create_cron_job.return_value = sample_cron_job run_at = datetime.now(timezone.utc) + timedelta(days=30) - result = await cron_manager.add_active_job( - name="Test Run Once Job", - cron_expression=None, - payload={"session": "test:group:123"}, - run_once=True, - run_at=run_at, - ) + with pytest.raises(CronJobSchedulingError, match="Invalid isoformat string"): + await cron_manager.add_active_job( + name="Test Run Once Job", + cron_expression=None, + payload={"session": "test:group:123"}, + run_once=True, + run_at=run_at, + ) - assert result == sample_cron_job call_kwargs = mock_db.create_cron_job.call_args.kwargs assert call_kwargs["run_once"] is True + assert call_kwargs["payload"]["run_at"] == run_at.isoformat() class TestUpdateJob: From 89096411d2e62bef61ad5be6de5b62332a1f051a Mon Sep 17 00:00:00 2001 From: Hongbro886 Date: Fri, 17 Apr 2026 10:07:48 +0800 Subject: [PATCH 06/14] docs: correct documentation URL from astrbot.app to docs.astrbot.app (#7612) * docs: correct documentation URL from astrbot.app to docs.astrbot.app * docs: correct documentation URL from astrbot.app to docs.astrbot.app --- README.md | 4 ++-- README_fr.md | 4 ++-- README_ja.md | 4 ++-- README_ru.md | 4 ++-- README_zh-TW.md | 4 ++-- README_zh.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6774d4305..e0d877681 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ uv tool upgrade astrbot --python 3.12 For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose. -Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). +Please refer to the official documentation: [Deploy AstrBot with Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). ### Deploy on RainYun @@ -139,7 +139,7 @@ yay -S astrbot-git **More deployment methods** -If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`. +If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`. ## Supported Messaging Platforms diff --git a/README_fr.md b/README_fr.md index c7dbeac3c..658a047cd 100644 --- a/README_fr.md +++ b/README_fr.md @@ -100,7 +100,7 @@ uv tool upgrade astrbot --python 3.12 Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose. -Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). +Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). ### Déployer sur RainYun @@ -138,7 +138,7 @@ yay -S astrbot-git **Autres méthodes de déploiement** -Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`. +Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://docs.astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`. ## Plateformes de messagerie prises en charge diff --git a/README_ja.md b/README_ja.md index 1e91a0ebc..d806b8a97 100644 --- a/README_ja.md +++ b/README_ja.md @@ -100,7 +100,7 @@ uv tool upgrade astrbot --python 3.12 コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。 -公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。 +公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。 ### 雨云でのデプロイ @@ -138,7 +138,7 @@ yay -S astrbot-git **その他のデプロイ方法** -パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。 +パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://docs.astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。 ## サポートされているメッセージプラットフォーム diff --git a/README_ru.md b/README_ru.md index a1fb90678..d40a21357 100644 --- a/README_ru.md +++ b/README_ru.md @@ -100,7 +100,7 @@ uv tool upgrade astrbot --python 3.12 Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose. -См. официальную документацию [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). +См. официальную документацию [Развёртывание AstrBot с Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). ### Развёртывание на RainYun @@ -138,7 +138,7 @@ yay -S astrbot-git **Другие способы развёртывания** -Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`). +Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://docs.astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`). ## Поддерживаемые платформы обмена сообщениями diff --git a/README_zh-TW.md b/README_zh-TW.md index da5f3abf4..4da29fe6e 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -100,7 +100,7 @@ uv tool upgrade astrbot --python 3.12 對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。 -請參考官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。 +請參考官方文件 [使用 Docker 部署 AstrBot](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。 ### 在雨雲上部署 @@ -138,7 +138,7 @@ yay -S astrbot-git **更多部署方式** -若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。 +若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。 ## 支援的訊息平台 diff --git a/README_zh.md b/README_zh.md index 52a1f3672..e7921fc0e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -100,7 +100,7 @@ uv tool upgrade astrbot --python 3.12 对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。 -请参考官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。 +请参考官方文档 [使用 Docker 部署 AstrBot](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。 ### 在 雨云 上部署 @@ -138,7 +138,7 @@ yay -S astrbot-git **更多部署方式** -若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。 +若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。 ## 支持的消息平台 From 087c793615728ed271cfa3b99e8faade3a538df4 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:58:47 +0800 Subject: [PATCH 07/14] revert "fix: scss import warning (#7528)" (#7616) This reverts commit ee85a4e50f389d47782c3da0c0210947df04141a. --- dashboard/src/scss/components/_VButtons.scss | 2 -- dashboard/src/scss/components/_VCard.scss | 2 -- dashboard/src/scss/components/_VField.scss | 2 -- dashboard/src/scss/components/_VShadow.scss | 2 -- dashboard/src/scss/components/_VTabs.scss | 2 -- dashboard/src/scss/layout/_container.scss | 2 -- dashboard/src/scss/layout/_sidebar.scss | 2 -- dashboard/src/scss/style.scss | 32 ++++++++++---------- 8 files changed, 16 insertions(+), 30 deletions(-) diff --git a/dashboard/src/scss/components/_VButtons.scss b/dashboard/src/scss/components/_VButtons.scss index fce0668f8..a7d095e95 100644 --- a/dashboard/src/scss/components/_VButtons.scss +++ b/dashboard/src/scss/components/_VButtons.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - // // Light Buttons // diff --git a/dashboard/src/scss/components/_VCard.scss b/dashboard/src/scss/components/_VCard.scss index abdd4dd5f..10dcaaf86 100644 --- a/dashboard/src/scss/components/_VCard.scss +++ b/dashboard/src/scss/components/_VCard.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - // Outline Card .v-card--variant-outlined { border-color: rgba(var(--v-theme-borderLight), 0.36); diff --git a/dashboard/src/scss/components/_VField.scss b/dashboard/src/scss/components/_VField.scss index 2798d0b44..97352acf5 100644 --- a/dashboard/src/scss/components/_VField.scss +++ b/dashboard/src/scss/components/_VField.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - .v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr, .v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start { border-radius: $border-radius-root 0 0 $border-radius-root; diff --git a/dashboard/src/scss/components/_VShadow.scss b/dashboard/src/scss/components/_VShadow.scss index 3a42f4459..aebe0e8e4 100644 --- a/dashboard/src/scss/components/_VShadow.scss +++ b/dashboard/src/scss/components/_VShadow.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - .elevation-10 { box-shadow: $box-shadow !important; } diff --git a/dashboard/src/scss/components/_VTabs.scss b/dashboard/src/scss/components/_VTabs.scss index 6112051b8..afb08fd46 100644 --- a/dashboard/src/scss/components/_VTabs.scss +++ b/dashboard/src/scss/components/_VTabs.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - .theme-tab { &.v-tabs { .v-tab { diff --git a/dashboard/src/scss/layout/_container.scss b/dashboard/src/scss/layout/_container.scss index 8f095211a..f1ca9294f 100644 --- a/dashboard/src/scss/layout/_container.scss +++ b/dashboard/src/scss/layout/_container.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - html { overflow-y: auto; } diff --git a/dashboard/src/scss/layout/_sidebar.scss b/dashboard/src/scss/layout/_sidebar.scss index 58472befd..52f9e404a 100644 --- a/dashboard/src/scss/layout/_sidebar.scss +++ b/dashboard/src/scss/layout/_sidebar.scss @@ -1,5 +1,3 @@ -@use '../variables' as *; - /*This is for the logo*/ .leftSidebar { border: 0px; diff --git a/dashboard/src/scss/style.scss b/dashboard/src/scss/style.scss index f77e38f32..6e02010ba 100644 --- a/dashboard/src/scss/style.scss +++ b/dashboard/src/scss/style.scss @@ -1,21 +1,21 @@ -@use './variables'; -@use 'vuetify/styles/main.sass'; -@use './override'; -@use './layout/container'; -@use './layout/sidebar'; +@import './variables'; +@import 'vuetify/styles/main.sass'; +@import './override'; +@import './layout/container'; +@import './layout/sidebar'; -@use './components/VButtons'; -@use './components/VCard'; -@use './components/VField'; -@use './components/VInput'; -@use './components/VNavigationDrawer'; -@use './components/VShadow'; -@use './components/VTextField'; -@use './components/VTabs'; -@use './components/VScrollbar'; -@use './components/CodeBlockDark'; +@import './components/VButtons'; +@import './components/VCard'; +@import './components/VField'; +@import './components/VInput'; +@import './components/VNavigationDrawer'; +@import './components/VShadow'; +@import './components/VTextField'; +@import './components/VTabs'; +@import './components/VScrollbar'; +@import './components/CodeBlockDark'; -@use './pages/dashboards'; +@import './pages/dashboards'; html, body { overscroll-behavior-y: none; From 5be6536f0e7e8793ee7233c9d84e11670e12689e 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: Fri, 17 Apr 2026 13:13:21 +0900 Subject: [PATCH 08/14] fix: support SOCKS proxies in updater requests (#7615) * fix: support SOCKS proxies in updater requests * fix: log updater HTTP status details * fix: clean partial updater downloads on failure * test: lock updater httpx client options * refactor: harden updater httpx configuration --- astrbot/core/star/updator.py | 4 +- astrbot/core/updator.py | 7 +- astrbot/core/zip_updator.py | 79 +++++--- tests/test_updator_socks.py | 359 +++++++++++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 33 deletions(-) create mode 100644 tests/test_updator_socks.py diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index 1a0c5fc26..2ae0039db 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -11,8 +11,8 @@ from ..updator import RepoZipUpdator class PluginUpdator(RepoZipUpdator): - def __init__(self, repo_mirror: str = "") -> None: - super().__init__(repo_mirror) + def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None: + super().__init__(repo_mirror, verify=verify) self.plugin_store_path = get_astrbot_plugin_path() def get_plugin_store_path(self) -> str: diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index df2cfb82c..73c837b88 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -7,7 +7,6 @@ import psutil from astrbot.core import logger from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_path -from astrbot.core.utils.io import download_file from .zip_updator import ReleaseInfo, RepoZipUpdator @@ -18,8 +17,8 @@ class AstrBotUpdator(RepoZipUpdator): 功能包括检查更新、下载更新文件、解压缩更新文件等 """ - def __init__(self, repo_mirror: str = "") -> None: - super().__init__(repo_mirror) + def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None: + super().__init__(repo_mirror, verify=verify) self.MAIN_PATH = get_astrbot_path() self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" @@ -176,7 +175,7 @@ class AstrBotUpdator(RepoZipUpdator): file_url = f"{proxy}/{file_url}" try: - await download_file(file_url, "temp.zip") + await self._download_file(file_url, "temp.zip") logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...") self.unzip_file("temp.zip", self.MAIN_PATH) except BaseException as e: diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index 6cea6b38d..116b55c57 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -1,15 +1,15 @@ import os import re import shutil -import ssl import zipfile +from pathlib import Path from typing import NoReturn -import aiohttp import certifi +import httpx from astrbot.core import logger -from astrbot.core.utils.io import download_file, on_error +from astrbot.core.utils.io import on_error from astrbot.core.utils.version_comparator import VersionComparator @@ -33,36 +33,53 @@ class ReleaseInfo: class RepoZipUpdator: - def __init__(self, repo_mirror: str = "") -> None: + def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None: self.repo_mirror = repo_mirror self.rm_on_error = on_error + self.httpx_verify = certifi.where() if verify is None else verify + + def _create_httpx_client(self, timeout: float = 30.0) -> httpx.AsyncClient: + return httpx.AsyncClient( + follow_redirects=True, + timeout=timeout, + trust_env=True, + verify=self.httpx_verify, + ) + + @staticmethod + def _truncate_response_body(body: str, max_len: int = 1000) -> str: + if len(body) <= max_len: + return body + return body[:max_len] + "...[truncated]" + + async def _download_file( + self, url: str, path: str, timeout: float = 1800.0 + ) -> None: + target_path = Path(path) + target_path.parent.mkdir(parents=True, exist_ok=True) + + try: + async with self._create_httpx_client(timeout=timeout) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + with target_path.open("wb") as file: + async for chunk in response.aiter_bytes(8192): + file.write(chunk) + except Exception as e: + logger.error(f"下载文件失败: {url} -> {target_path}, 错误: {e}") + if self.rm_on_error and target_path.exists(): + target_path.unlink() + raise async def fetch_release_info(self, url: str, latest: bool = True) -> list: """请求版本信息。 返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。 """ try: - ssl_context = ssl.create_default_context( - cafile=certifi.where(), - ) # 新增:创建基于 certifi 的 SSL 上下文 - connector = aiohttp.TCPConnector( - ssl=ssl_context, - ) # 新增:使用 TCPConnector 指定 SSL 上下文 - async with ( - aiohttp.ClientSession( - trust_env=True, - connector=connector, - ) as session, - session.get(url) as response, - ): - # 检查 HTTP 状态码 - if response.status != 200: - text = await response.text() - logger.error( - f"请求 {url} 失败,状态码: {response.status}, 内容: {text}", - ) - raise Exception(f"请求失败,状态码: {response.status}") - result = await response.json() + async with self._create_httpx_client() as client: + response = await client.get(url) + response.raise_for_status() + result = response.json() if not result: return [] # if latest: @@ -80,9 +97,17 @@ class RepoZipUpdator: "zipball_url": release["zipball_url"], }, ) + except httpx.HTTPStatusError as e: + response_body = "" + if e.response is not None: + response_body = self._truncate_response_body(e.response.text) + logger.error( + f"请求 {url} 失败,状态码: {e.response.status_code}, 内容: {response_body}", + ) + raise Exception("解析版本信息失败") from e except Exception as e: logger.error(f"解析版本信息时发生异常: {e}") - raise Exception("解析版本信息失败") + raise Exception("解析版本信息失败") from e return ret def github_api_release_parser(self, releases: list) -> list: @@ -186,7 +211,7 @@ class RepoZipUpdator: f"检查到设置了镜像站,将使用镜像站下载 {author}/{repo} 仓库源码: {release_url}", ) - await download_file(release_url, target_path + ".zip") + await self._download_file(release_url, target_path + ".zip") def parse_github_url(self, url: str): """使用正则表达式解析 GitHub 仓库 URL,支持 `.git` 后缀和 `tree/branch` 结构 diff --git a/tests/test_updator_socks.py b/tests/test_updator_socks.py new file mode 100644 index 000000000..6dec6fe0f --- /dev/null +++ b/tests/test_updator_socks.py @@ -0,0 +1,359 @@ +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace + +import certifi +import httpx +import pytest + +from astrbot.core.zip_updator import RepoZipUpdator + + +class _FakeJSONResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self): + return self._payload + + +class _FakeStreamResponse: + def __init__(self, payload: bytes): + self._payload = payload + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def raise_for_status(self) -> None: + return None + + async def aiter_bytes(self, chunk_size: int = 8192): + for start in range(0, len(self._payload), chunk_size): + yield self._payload[start : start + chunk_size] + + +class _FakeFailingStreamResponse: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def raise_for_status(self) -> None: + return None + + async def aiter_bytes(self, chunk_size: int = 8192): # noqa: ARG002 + yield b"partial" + raise RuntimeError("stream interrupted") + + +class _FakeStatusErrorResponse: + def __init__(self, status_code: int, body: str, url: str): + self._status_code = status_code + self._body = body + self._url = url + + def raise_for_status(self) -> None: + request = httpx.Request("GET", self._url) + response = httpx.Response( + self._status_code, + text=self._body, + request=request, + ) + raise httpx.HTTPStatusError( + "status error", + request=request, + response=response, + ) + + +@dataclass +class _FakeAsyncClientState: + json_payload: list[dict] = field(default_factory=list) + stream_payload: bytes = b"" + init_kwargs: dict | None = None + requested_urls: list[str] = field(default_factory=list) + stream_urls: list[str] = field(default_factory=list) + + +class _FakeStatusErrorAsyncClient: + def __init__(self, response: _FakeStatusErrorResponse): + self._response = response + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def get(self, url: str): + return self._response + + +class _FakeFailingStreamAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def stream(self, method: str, url: str): # noqa: ARG002 + return _FakeFailingStreamResponse() + + +def _build_fake_httpx_module(state: _FakeAsyncClientState) -> SimpleNamespace: + class _FakeAsyncClient: + def __init__(self, **kwargs): + state.init_kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def get(self, url: str): + state.requested_urls.append(url) + return _FakeJSONResponse(state.json_payload) + + def stream(self, method: str, url: str): + assert method == "GET" + state.stream_urls.append(url) + return _FakeStreamResponse(state.stream_payload) + + return SimpleNamespace( + AsyncClient=_FakeAsyncClient, + HTTPStatusError=httpx.HTTPStatusError, + ) + + +@pytest.fixture +def fake_async_client_state() -> _FakeAsyncClientState: + return _FakeAsyncClientState() + + +@pytest.mark.asyncio +async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support( + monkeypatch: pytest.MonkeyPatch, + fake_async_client_state: _FakeAsyncClientState, +) -> None: + import astrbot.core.zip_updator as zip_updator_module + + fake_async_client_state.json_payload = [ + { + "name": "AstrBot v4.23.2", + "published_at": "2026-04-16T00:00:00Z", + "body": "fix updater socks proxy support", + "tag_name": "v4.23.2", + "zipball_url": "https://example.com/astrbot.zip", + } + ] + + monkeypatch.setattr( + zip_updator_module, + "aiohttp", + SimpleNamespace( + ClientSession=lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError( + "fetch_release_info should not use aiohttp.ClientSession" + ) + ) + ), + raising=False, + ) + monkeypatch.setattr( + zip_updator_module, + "httpx", + _build_fake_httpx_module(fake_async_client_state), + raising=False, + ) + + release_info = await RepoZipUpdator().fetch_release_info( + "https://api.soulter.top/releases" + ) + + assert release_info == [ + { + "version": "AstrBot v4.23.2", + "published_at": "2026-04-16T00:00:00Z", + "body": "fix updater socks proxy support", + "tag_name": "v4.23.2", + "zipball_url": "https://example.com/astrbot.zip", + } + ] + assert fake_async_client_state.requested_urls == ["https://api.soulter.top/releases"] + assert fake_async_client_state.init_kwargs is not None + assert fake_async_client_state.init_kwargs["follow_redirects"] is True + assert fake_async_client_state.init_kwargs["timeout"] == 30.0 + assert fake_async_client_state.init_kwargs["trust_env"] is True + assert fake_async_client_state.init_kwargs["verify"] == certifi.where() + + +@pytest.mark.asyncio +async def test_download_from_repo_url_uses_httpx_stream_for_zip_download( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + fake_async_client_state: _FakeAsyncClientState, +) -> None: + import astrbot.core.zip_updator as zip_updator_module + + fake_async_client_state.stream_payload = b"zip-data" + + async def fake_fetch_release_info(self, url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v4.23.2", + "published_at": "2026-04-16T00:00:00Z", + "body": "fix updater socks proxy support", + "tag_name": "v4.23.2", + "zipball_url": "https://example.com/archive.zip", + } + ] + + monkeypatch.setattr(RepoZipUpdator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr( + zip_updator_module, + "download_file", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("download_from_repo_url should not use aiohttp download_file") + ), + raising=False, + ) + monkeypatch.setattr( + zip_updator_module, + "httpx", + _build_fake_httpx_module(fake_async_client_state), + raising=False, + ) + + target_path = tmp_path / "AstrBot" + await RepoZipUpdator().download_from_repo_url( + str(target_path), + "https://github.com/AstrBotDevs/AstrBot", + ) + + assert (tmp_path / "AstrBot.zip").read_bytes() == b"zip-data" + assert fake_async_client_state.stream_urls == ["https://example.com/archive.zip"] + assert fake_async_client_state.init_kwargs is not None + assert fake_async_client_state.init_kwargs["follow_redirects"] is True + assert fake_async_client_state.init_kwargs["timeout"] == 1800.0 + assert fake_async_client_state.init_kwargs["trust_env"] is True + assert fake_async_client_state.init_kwargs["verify"] == certifi.where() + + +def test_create_httpx_client_uses_custom_verify_setting( + monkeypatch: pytest.MonkeyPatch, + fake_async_client_state: _FakeAsyncClientState, +) -> None: + import astrbot.core.zip_updator as zip_updator_module + + custom_verify = "/tmp/custom-ca.pem" + + monkeypatch.setattr( + zip_updator_module, + "httpx", + _build_fake_httpx_module(fake_async_client_state), + raising=False, + ) + + RepoZipUpdator(verify=custom_verify)._create_httpx_client(timeout=45.0) + + assert fake_async_client_state.init_kwargs is not None + assert fake_async_client_state.init_kwargs["follow_redirects"] is True + assert fake_async_client_state.init_kwargs["timeout"] == 45.0 + assert fake_async_client_state.init_kwargs["trust_env"] is True + assert fake_async_client_state.init_kwargs["verify"] == custom_verify + + +@pytest.mark.asyncio +async def test_fetch_release_info_logs_status_code_and_truncated_body_on_http_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import astrbot.core.zip_updator as zip_updator_module + + url = "https://api.soulter.top/releases" + body = "x" * 1005 + log_messages: list[str] = [] + + monkeypatch.setattr( + RepoZipUpdator, + "_create_httpx_client", + staticmethod( + lambda timeout=30.0: _FakeStatusErrorAsyncClient( # noqa: ARG005 + _FakeStatusErrorResponse(502, body, url) + ) + ), + ) + monkeypatch.setattr( + zip_updator_module.logger, + "error", + lambda message: log_messages.append(message), + ) + + with pytest.raises(Exception, match="解析版本信息失败"): + await RepoZipUpdator().fetch_release_info(url) + + assert any("状态码: 502" in message for message in log_messages) + assert any("内容: " in message for message in log_messages) + assert any("...[truncated]" in message for message in log_messages) + + +@pytest.mark.asyncio +async def test_download_file_removes_partial_file_when_stream_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + RepoZipUpdator, + "_create_httpx_client", + staticmethod( + lambda timeout=30.0: _FakeFailingStreamAsyncClient() # noqa: ARG005 + ), + ) + + target_path = tmp_path / "partial.zip" + + with pytest.raises(RuntimeError, match="stream interrupted"): + await RepoZipUpdator()._download_file( + "https://example.com/archive.zip", + str(target_path), + ) + + assert not target_path.exists() + + +@pytest.mark.asyncio +async def test_download_file_logs_url_and_target_path_on_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import astrbot.core.zip_updator as zip_updator_module + + url = "https://example.com/archive.zip" + target_path = tmp_path / "logged-partial.zip" + log_messages: list[str] = [] + + monkeypatch.setattr( + RepoZipUpdator, + "_create_httpx_client", + staticmethod( + lambda timeout=30.0: _FakeFailingStreamAsyncClient() # noqa: ARG005 + ), + ) + monkeypatch.setattr( + zip_updator_module.logger, + "error", + lambda message: log_messages.append(message), + ) + + with pytest.raises(RuntimeError, match="stream interrupted"): + await RepoZipUpdator()._download_file(url, str(target_path)) + + assert any(url in message for message in log_messages) + assert any(str(target_path) in message for message in log_messages) From 0ca6ba91b15b303fe6f6c452eff11edbde6b72d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:48:52 +0800 Subject: [PATCH 09/14] feat: add MiniMax Token Plan provider with hardcoded model list (#7609) * feat: add MiniMax Token Plan provider with hardcoded model list (fix #7585) - Add new provider 'minimax_token_plan' for MiniMax Token Plan users - Inherit ProviderAnthropic to reuse all chat/completion logic - Hardcode api_base to https://api.minimaxi.com/anthropic - get_models() returns hardcoded list: MiniMax-M2.7, M2.5, M2.1, M2 - Highspeed models excluded (require premium tier) - Reason for hardcoding: Token Plan API does not expose /models endpoint - Fixes: https://github.com/AstrBotDevs/AstrBot/issues/7585 * fix: remove api_base from config template and add model validation - Remove api_base from default_config_tmpl (always overridden, misleading) - Add model validation against MINIMAX_TOKEN_PLAN_MODELS - Raise clear ValueError if user configures an unsupported model Addressed Sourcery AI review comments. * fix: use custom_headers for Bearer token auth instead of auth_header MiniMax Token Plan requires Authorization: Bearer header. Use custom_headers to inject the correct auth header instead of the non-functional auth_header key. Addressed Gemini Code Assist review comment. * fix: update MiniMax Token Plan provider adapter and documentation to English * feat: add MiniMax Token Plan configuration and icon support * feat: remove default configuration template from MiniMax Token Plan provider adapter --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/config/default.py | 15 ++++- astrbot/core/provider/manager.py | 4 ++ .../sources/minimax_token_plan_source.py | 55 +++++++++++++++++++ dashboard/src/utils/providerUtils.js | 3 +- 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 astrbot/core/provider/sources/minimax_token_plan_source.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0237b176d..527cfbf9b 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1206,7 +1206,7 @@ CONFIG_METADATA_2 = { "provider_type": "chat_completion", "enable": True, "key": [], - "api_base": "https://api.kimi.com/coding/", + "api_base": "https://api.kimi.com/coding", "timeout": 120, "proxy": "", "custom_headers": {"User-Agent": "claude-code/0.1.0"}, @@ -1236,6 +1236,19 @@ CONFIG_METADATA_2 = { "proxy": "", "custom_headers": {}, }, + "MiniMax Token Plan": { + "id": "minimax-token-plan", + "provider": "minimax-token-plan", + "type": "minimax_token_plan", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://api.minimaxi.com/anthropic", + "timeout": 120, + "proxy": "", + "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, + }, "xAI": { "id": "xai", "provider": "xai", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index b4ff2742d..0dfdbdcf6 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -363,6 +363,10 @@ class ProviderManager: ) case "longcat_chat_completion": from .sources.longcat_source import ProviderLongCat as ProviderLongCat + case "minimax_token_plan": + from .sources.minimax_token_plan_source import ( + ProviderMiniMaxTokenPlan as ProviderMiniMaxTokenPlan, + ) case "zhipu_chat_completion": from .sources.zhipu_source import ProviderZhipu as ProviderZhipu case "groq_chat_completion": diff --git a/astrbot/core/provider/sources/minimax_token_plan_source.py b/astrbot/core/provider/sources/minimax_token_plan_source.py new file mode 100644 index 000000000..5a578424f --- /dev/null +++ b/astrbot/core/provider/sources/minimax_token_plan_source.py @@ -0,0 +1,55 @@ +from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic + +from ..register import register_provider_adapter + +MINIMAX_TOKEN_PLAN_MODELS = [ + "MiniMax-M2.7", + "MiniMax-M2.5", + "MiniMax-M2.1", + "MiniMax-M2", +] + + +@register_provider_adapter( + "minimax_token_plan", + "MiniMax Token Plan Provider Adapter", +) +class ProviderMiniMaxTokenPlan(ProviderAnthropic): + """MiniMax Token Plan provider. + + The Token Plan API does not support the /models endpoint, so get_models() + returns a hard-coded model list. This is a Token Plan API limitation. + See https://github.com/AstrBotDevs/AstrBot/issues/7585 for details. + """ + + def __init__( + self, + provider_config, + provider_settings, + ) -> None: + # Keep api_base fixed; Token Plan users do not need to configure it. + provider_config["api_base"] = "https://api.minimaxi.com/anthropic" + # MiniMax Token Plan requires the Authorization: Bearer header. + key = provider_config.get("key", "") + actual_key = key[0] if isinstance(key, list) else key + provider_config.setdefault("custom_headers", {})["Authorization"] = ( + f"Bearer {actual_key}" + ) + + super().__init__( + provider_config, + provider_settings, + ) + + configured_model = provider_config.get("model", "MiniMax-M2.7") + if configured_model not in MINIMAX_TOKEN_PLAN_MODELS: + raise ValueError( + f"Unsupported model: {configured_model!r}. " + f"Supported models: {', '.join(MINIMAX_TOKEN_PLAN_MODELS)}" + ) + + self.set_model(configured_model) + + async def get_models(self) -> list[str]: + """Return the hard-coded known model list because Token Plan cannot fetch it dynamically.""" + return MINIMAX_TOKEN_PLAN_MODELS.copy() diff --git a/dashboard/src/utils/providerUtils.js b/dashboard/src/utils/providerUtils.js index b116fd73d..dbf09b83a 100644 --- a/dashboard/src/utils/providerUtils.js +++ b/dashboard/src/utils/providerUtils.js @@ -23,7 +23,7 @@ export function getProviderIcon(type) { 'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', 'kimi': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', 'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', - 'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/longcat-color.svg', + 'longcat': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/longcat-color.svg', 'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg', 'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg', "coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg", @@ -33,6 +33,7 @@ export function getProviderIcon(type) { 'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg', 'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg', 'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg', + 'minimax-token-plan': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg', 'mimo': 'https://platform.xiaomimimo.com/favicon.874c9507.png', '302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg', 'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg', From a1a7de1c57613f9f5fd80497c810fa68735bfcb9 Mon Sep 17 00:00:00 2001 From: SaintaToken Date: Fri, 17 Apr 2026 17:20:13 +0800 Subject: [PATCH 10/14] fix: correct minor text inconsistencies in README files and document (#7602) * fix: correct minor text inconsistencies in README files and documentation * Update README_zh.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * feat: add buffered intermediate messages for non-streaming agent loop * Revert "feat: add buffered intermediate messages for non-streaming agent loop" This reverts commit 803762c99abf8adfba01d7f662021ba9190adfec. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 2 +- README_zh-TW.md | 4 ++-- README_zh.md | 6 +++--- docs/zh/what-is-astrbot.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e0d877681..214f5ec69 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ astrbot run > AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12. > [!NOTE] -> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s). +> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s). Update `astrbot`: diff --git a/README_zh-TW.md b/README_zh-TW.md index 4da29fe6e..6edc905e3 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -32,7 +32,7 @@ 文件 | Blog | 路線圖 | -問題回報 +問題回報 | Email @@ -160,7 +160,7 @@ yay -S astrbot-git | KOOK | 官方維護 | | Misskey | 官方維護 | | Mattermost | 官方維護 | -| Whatsapp(即將支援) | 官方維護 | +| WhatsApp(即將支援) | 官方維護 | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 | | [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 | diff --git a/README_zh.md b/README_zh.md index e7921fc0e..7ff07e35a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -31,12 +31,12 @@ 文档 | 博客 | 路线图 | -问题提交 +问题提交 | Email -AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。 +AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。 ![landingpage](https://github.com/user-attachments/assets/45fc5699-cddf-4e21-af35-13040706f6c0) @@ -160,7 +160,7 @@ yay -S astrbot-git | **KOOK** | 官方维护 | | **Misskey** | 官方维护 | | **Mattermost** | 官方维护 | -| **Whatsapp (将支持)** | 官方维护 | +| **WhatsApp(将支持)** | 官方维护 | | [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 | | [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 | | [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 | diff --git a/docs/zh/what-is-astrbot.md b/docs/zh/what-is-astrbot.md index cad1411b5..f14b4cd8f 100644 --- a/docs/zh/what-is-astrbot.md +++ b/docs/zh/what-is-astrbot.md @@ -6,7 +6,7 @@ outline: deep ## 简介 -AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。 +AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。 ## 文档概览 From 47f78be3784a7d79c73ebe3cd270dc9f8e59bbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=82=E5=A3=B9?= <137363396+KBVsent@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:36:47 +0900 Subject: [PATCH 11/14] fix: display cron last_run_at in local timezone (#7625) --- astrbot/core/cron/manager.py | 4 +++- astrbot/dashboard/routes/cron.py | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index 5b2c6ee9e..aa11bb601 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -196,7 +196,9 @@ class CronJobManager: def _get_next_run_time(self, job_id: str): aps_job = self.scheduler.get_job(job_id) - return aps_job.next_run_time if aps_job else None + if not aps_job or aps_job.next_run_time is None: + return None + return aps_job.next_run_time.astimezone(timezone.utc) async def _run_job(self, job_id: str) -> None: job = await self.db.get_cron_job(job_id) diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py index af26c6525..8417c970c 100644 --- a/astrbot/dashboard/routes/cron.py +++ b/astrbot/dashboard/routes/cron.py @@ -1,5 +1,5 @@ import traceback -from datetime import datetime +from datetime import datetime, timezone from quart import jsonify, request @@ -26,8 +26,12 @@ class CronRoute(Route): def _serialize_job(self, job) -> dict: data = job.model_dump() if hasattr(job, "model_dump") else job.__dict__ for k in ["created_at", "updated_at", "last_run_at", "next_run_time"]: - if isinstance(data.get(k), datetime): - data[k] = data[k].isoformat() + v = data.get(k) + if isinstance(v, datetime): + # Attach UTC + if v.tzinfo is None: + v = v.replace(tzinfo=timezone.utc) + data[k] = v.isoformat() # expose note explicitly for UI (prefer payload.note then description) payload = data.get("payload") or {} data["note"] = payload.get("note") or data.get("description") or "" From 352455197dd9d44f606dcaec8a7049df0fdb941f Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:57:27 +0800 Subject: [PATCH 12/14] feat: implement FTS5 support in knowledge base sparse retrieving stage (#7648) * feat: implement FTS5 support in DocumentStorage and SparseRetriever with tokenizer enhancements * feat: optimize FTS row handling in DocumentStorage and update query tokenization in SparseRetriever --- .../db/vec_db/faiss_impl/document_storage.py | 358 +++++++++++++++++- .../core/knowledge_base/retrieval/__init__.py | 37 +- .../retrieval/sparse_retriever.py | 71 +++- .../knowledge_base/retrieval/tokenizer.py | 39 ++ tests/unit/test_document_storage_fts.py | 75 ++++ tests/unit/test_sparse_retriever.py | 93 +++++ 6 files changed, 652 insertions(+), 21 deletions(-) create mode 100644 astrbot/core/knowledge_base/retrieval/tokenizer.py create mode 100644 tests/unit/test_document_storage_fts.py create mode 100644 tests/unit/test_sparse_retriever.py diff --git a/astrbot/core/db/vec_db/faiss_impl/document_storage.py b/astrbot/core/db/vec_db/faiss_impl/document_storage.py index 2adae69cc..116c43be1 100644 --- a/astrbot/core/db/vec_db/faiss_impl/document_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/document_storage.py @@ -2,13 +2,22 @@ import json import os from contextlib import asynccontextmanager from datetime import datetime +from pathlib import Path -from sqlalchemy import Column, Text +from sqlalchemy import Column, Text, bindparam from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlmodel import Field, MetaData, SQLModel, col, func, select, text from astrbot.core import logger +from astrbot.core.knowledge_base.retrieval.tokenizer import ( + build_fts5_or_query, + load_stopwords, + to_fts5_search_text, +) + +FTS_TABLE_NAME = "documents_fts" +FTS_REBUILD_BATCH_SIZE = 1000 class BaseDocModel(SQLModel, table=False): @@ -42,6 +51,10 @@ class DocumentStorage: os.path.dirname(__file__), "sqlite_init.sql", ) + self.fts5_available = False + self._fts_contentless_delete = False + self._fts_index_ready = False + self._stopwords: set[str] | None = None async def initialize(self) -> None: """Initialize the SQLite database and create the documents table if it doesn't exist.""" @@ -78,8 +91,49 @@ class DocumentStorage: except BaseException: pass + await self._initialize_fts5(conn) await conn.commit() + async def _initialize_fts5(self, executor) -> None: + try: + try: + await executor.execute( + text( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME} + USING fts5( + search_text, + content='', + contentless_delete=1, + tokenize='unicode61' + ) + """, + ), + ) + self._fts_contentless_delete = True + except Exception: + await executor.execute( + text( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME} + USING fts5( + search_text, + content='', + tokenize='unicode61' + ) + """, + ), + ) + self._fts_contentless_delete = False + self.fts5_available = True + except Exception as e: + self.fts5_available = False + self._fts_contentless_delete = False + logger.warning( + f"SQLite FTS5 is unavailable for document storage {self.db_path}; " + f"falling back to in-memory BM25 sparse retrieval: {e}", + ) + async def connect(self) -> None: """Connect to the SQLite database.""" if self.engine is None: @@ -100,6 +154,18 @@ class DocumentStorage: async with self.async_session_maker() as session: # type: ignore yield session + @property + def stopwords(self) -> set[str]: + if self._stopwords is None: + stopwords_path = ( + Path(__file__).parents[3] + / "knowledge_base" + / "retrieval" + / "hit_stopwords.txt" + ) + self._stopwords = load_stopwords(stopwords_path) + return self._stopwords + async def get_documents( self, metadata_filters: dict, @@ -172,6 +238,8 @@ class DocumentStorage: ) session.add(document) await session.flush() # Flush to get the ID + if document.id is not None: + await self._insert_fts_row(session, int(document.id), text) return document.id # type: ignore async def insert_documents_batch( @@ -209,6 +277,7 @@ class DocumentStorage: session.add(document) await session.flush() # Flush to get all IDs + await self._insert_fts_rows_batch(session, documents, texts) return [doc.id for doc in documents] # type: ignore async def delete_document_by_doc_id(self, doc_id: str) -> None: @@ -226,6 +295,8 @@ class DocumentStorage: document = result.scalar_one_or_none() if document: + if document.id is not None: + await self._delete_fts_row(session, int(document.id), document.text) await session.delete(document) async def get_document_by_doc_id(self, doc_id: str): @@ -265,9 +336,13 @@ class DocumentStorage: document = result.scalar_one_or_none() if document: + if document.id is not None: + await self._delete_fts_row(session, int(document.id), document.text) document.text = new_text document.updated_at = datetime.now() session.add(document) + if document.id is not None: + await self._insert_fts_row(session, int(document.id), new_text) async def delete_documents(self, metadata_filters: dict) -> None: """Delete documents by their metadata filters. @@ -293,6 +368,7 @@ class DocumentStorage: result = await session.execute(query) documents = result.scalars().all() + await self._delete_fts_rows_batch(session, documents) for doc in documents: await session.delete(doc) @@ -323,6 +399,286 @@ class DocumentStorage: count = result.scalar_one_or_none() return count if count is not None else 0 + async def ensure_fts_index(self) -> bool: + """Ensure the FTS5 sparse index exists and matches the documents table.""" + if not self.fts5_available: + return False + if self._fts_index_ready: + return True + + assert self.engine is not None, "Database connection is not initialized." + + async with self.get_session() as session: + doc_count = await self._count_documents_in_session(session) + fts_count = await self._count_fts_rows(session) + if doc_count == fts_count: + self._fts_index_ready = True + return True + + logger.info( + f"Rebuilding FTS5 sparse index for {self.db_path}: " + f"documents={doc_count}, fts_rows={fts_count}", + ) + await self.rebuild_fts_index() + return self.fts5_available + + async def rebuild_fts_index(self) -> None: + """Rebuild the contentless FTS5 sparse index from documents.""" + if not self.fts5_available: + return + + assert self.engine is not None, "Database connection is not initialized." + + async with self.get_session() as session, session.begin(): + await session.execute(text(f"DROP TABLE IF EXISTS {FTS_TABLE_NAME}")) + await self._initialize_fts5(session) + if not self.fts5_available: + return + + last_id = 0 + while True: + query = ( + select(Document) + .where(col(Document.id) > last_id) + .order_by(col(Document.id)) + .limit(FTS_REBUILD_BATCH_SIZE) + ) + result = await session.execute(query) + documents = result.scalars().all() + if not documents: + break + + await self._insert_fts_rows_batch( + session, + documents, + [doc.text for doc in documents], + ) + last_id = int(documents[-1].id or last_id) + + self._fts_index_ready = True + + async def search_sparse( + self, + query_tokens: list[str], + limit: int, + ) -> list[dict] | None: + """Search chunks using the FTS5 sparse index. + + Returns None when FTS5 is unavailable so callers can fall back to another + sparse retrieval implementation. + """ + if limit <= 0: + return [] + if not await self.ensure_fts_index(): + return None + + match_query = build_fts5_or_query(query_tokens) + if not match_query: + return [] + + async with self.get_session() as session: + try: + result = await session.execute( + text( + f""" + SELECT + d.id AS id, + d.doc_id AS doc_id, + d.text AS text, + d.metadata AS metadata, + d.created_at AS created_at, + d.updated_at AS updated_at, + bm25({FTS_TABLE_NAME}) AS score + FROM {FTS_TABLE_NAME} + JOIN documents d ON d.id = {FTS_TABLE_NAME}.rowid + WHERE {FTS_TABLE_NAME} MATCH :query + ORDER BY score ASC, d.id ASC + LIMIT :limit + """, + ), + {"query": match_query, "limit": int(limit)}, + ) + except Exception as e: + logger.warning( + f"FTS5 sparse search failed for {self.db_path}; " + f"falling back to in-memory BM25: {e}", + ) + self.fts5_available = False + return None + + rows = result.mappings().all() + return [ + { + "id": row["id"], + "doc_id": row["doc_id"], + "text": row["text"], + "metadata": row["metadata"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "score": float(row["score"]), + } + for row in rows + ] + + async def _count_documents_in_session(self, session: AsyncSession) -> int: + result = await session.execute(select(func.count(col(Document.id)))) + count = result.scalar_one_or_none() + return int(count or 0) + + async def _count_fts_rows(self, session: AsyncSession) -> int: + result = await session.execute( + text(f"SELECT count(*) FROM {FTS_TABLE_NAME}"), + ) + count = result.scalar_one_or_none() + return int(count or 0) + + async def _insert_fts_row( + self, + session: AsyncSession, + rowid: int, + content: str, + ) -> None: + if not self.fts5_available: + return + + search_text = to_fts5_search_text(content, self.stopwords) + await session.execute( + text( + f""" + INSERT INTO {FTS_TABLE_NAME}(rowid, search_text) + VALUES (:rowid, :search_text) + """, + ), + {"rowid": rowid, "search_text": search_text}, + ) + + async def _insert_fts_rows_batch( + self, + session: AsyncSession, + documents: list[Document], + contents: list[str], + ) -> None: + if not self.fts5_available: + return + + fts_params = [ + { + "rowid": int(doc.id), + "search_text": to_fts5_search_text(content, self.stopwords), + } + for doc, content in zip(documents, contents) + if doc.id is not None + ] + if not fts_params: + return + + await session.execute( + text( + f""" + INSERT INTO {FTS_TABLE_NAME}(rowid, search_text) + VALUES (:rowid, :search_text) + """, + ), + fts_params, + ) + + async def _delete_fts_row( + self, + session: AsyncSession, + rowid: int, + content: str, + ) -> None: + if not self.fts5_available: + return + + if self._fts_contentless_delete: + await session.execute( + text(f"DELETE FROM {FTS_TABLE_NAME} WHERE rowid = :rowid"), + {"rowid": rowid}, + ) + return + + if not await self._fts_row_exists(session, rowid): + return + + search_text = to_fts5_search_text(content, self.stopwords) + await session.execute( + text( + f""" + INSERT INTO {FTS_TABLE_NAME}({FTS_TABLE_NAME}, rowid, search_text) + VALUES ('delete', :rowid, :search_text) + """, + ), + {"rowid": rowid, "search_text": search_text}, + ) + + async def _delete_fts_rows_batch( + self, + session: AsyncSession, + documents: list[Document], + ) -> None: + if not self.fts5_available: + return + + docs_with_ids = [doc for doc in documents if doc.id is not None] + if not docs_with_ids: + return + + if self._fts_contentless_delete: + await session.execute( + text(f"DELETE FROM {FTS_TABLE_NAME} WHERE rowid = :rowid"), + [{"rowid": int(doc.id)} for doc in docs_with_ids if doc.id is not None], + ) + return + + existing_rowids = await self._existing_fts_rowids( + session, + [int(doc.id) for doc in docs_with_ids if doc.id is not None], + ) + fts_params = [ + { + "rowid": int(doc.id), + "search_text": to_fts5_search_text(doc.text, self.stopwords), + } + for doc in docs_with_ids + if doc.id is not None and int(doc.id) in existing_rowids + ] + if not fts_params: + return + + await session.execute( + text( + f""" + INSERT INTO {FTS_TABLE_NAME}({FTS_TABLE_NAME}, rowid, search_text) + VALUES ('delete', :rowid, :search_text) + """, + ), + fts_params, + ) + + async def _fts_row_exists(self, session: AsyncSession, rowid: int) -> bool: + result = await session.execute( + text(f"SELECT 1 FROM {FTS_TABLE_NAME} WHERE rowid = :rowid LIMIT 1"), + {"rowid": rowid}, + ) + return result.scalar_one_or_none() is not None + + async def _existing_fts_rowids( + self, + session: AsyncSession, + rowids: list[int], + ) -> set[int]: + if not rowids: + return set() + + result = await session.execute( + text( + f"SELECT rowid FROM {FTS_TABLE_NAME} WHERE rowid IN :rowids" + ).bindparams(bindparam("rowids", expanding=True)), + {"rowids": rowids}, + ) + return {int(row[0]) for row in result.fetchall()} + async def get_user_ids(self) -> list[str]: """Retrieve all user IDs from the documents table. diff --git a/astrbot/core/knowledge_base/retrieval/__init__.py b/astrbot/core/knowledge_base/retrieval/__init__.py index f5d196cb9..b7c88075d 100644 --- a/astrbot/core/knowledge_base/retrieval/__init__.py +++ b/astrbot/core/knowledge_base/retrieval/__init__.py @@ -1,8 +1,11 @@ """检索模块""" -from .manager import RetrievalManager, RetrievalResult -from .rank_fusion import FusedResult, RankFusion -from .sparse_retriever import SparseResult, SparseRetriever +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .manager import RetrievalManager, RetrievalResult + from .rank_fusion import FusedResult, RankFusion + from .sparse_retriever import SparseResult, SparseRetriever __all__ = [ "FusedResult", @@ -12,3 +15,31 @@ __all__ = [ "SparseResult", "SparseRetriever", ] + + +def __getattr__(name: str): + if name in {"RetrievalManager", "RetrievalResult"}: + from .manager import RetrievalManager, RetrievalResult + + return { + "RetrievalManager": RetrievalManager, + "RetrievalResult": RetrievalResult, + }[name] + + if name in {"FusedResult", "RankFusion"}: + from .rank_fusion import FusedResult, RankFusion + + return { + "FusedResult": FusedResult, + "RankFusion": RankFusion, + }[name] + + if name in {"SparseResult", "SparseRetriever"}: + from .sparse_retriever import SparseResult, SparseRetriever + + return { + "SparseResult": SparseResult, + "SparseRetriever": SparseRetriever, + }[name] + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py index 9d485dab2..f06eb5090 100644 --- a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py +++ b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py @@ -8,10 +8,13 @@ import os from dataclasses import dataclass from typing import TYPE_CHECKING -import jieba from rank_bm25 import BM25Okapi from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase +from astrbot.core.knowledge_base.retrieval.tokenizer import ( + load_stopwords, + tokenize_text, +) if TYPE_CHECKING: from astrbot.core.db.vec_db.faiss_impl import FaissVecDB @@ -47,13 +50,9 @@ class SparseRetriever: self.kb_db = kb_db self._index_cache = {} # 缓存 BM25 索引 - with open( + self.hit_stopwords = load_stopwords( os.path.join(os.path.dirname(__file__), "hit_stopwords.txt"), - encoding="utf-8", - ) as f: - self.hit_stopwords = { - word.strip() for word in set(f.read().splitlines()) if word.strip() - } + ) async def retrieve( self, @@ -72,7 +71,52 @@ class SparseRetriever: List[SparseResult]: 检索结果列表 """ - # 1. 获取所有相关块 + fts_results = [] + fallback_kb_ids = [] + query_tokens = tokenize_text(query, self.hit_stopwords) + for kb_id in kb_ids: + vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db") + if not vec_db: + continue + top_k_sparse = kb_options.get(kb_id, {}).get("top_k_sparse", 50) + result = await vec_db.document_storage.search_sparse( + query_tokens=query_tokens, + limit=top_k_sparse, + ) + if result is None: + fallback_kb_ids.append(kb_id) + continue + + for doc in result: + chunk_md = json.loads(doc["metadata"]) + fts_results.append( + SparseResult( + chunk_id=doc["doc_id"], + chunk_index=chunk_md["chunk_index"], + doc_id=chunk_md["kb_doc_id"], + kb_id=kb_id, + content=doc["text"], + score=-float(doc["score"]), + ), + ) + + fallback_results = [] + if fallback_kb_ids: + fallback_results = await self._retrieve_with_bm25( + query=query, + kb_ids=fallback_kb_ids, + kb_options=kb_options, + ) + results = fts_results + fallback_results + results.sort(key=lambda x: x.score, reverse=True) + return results + + async def _retrieve_with_bm25( + self, + query: str, + kb_ids: list[str], + kb_options: dict, + ) -> list[SparseResult]: top_k_sparse = 0 chunks = [] for kb_id in kb_ids: @@ -103,20 +147,13 @@ class SparseRetriever: # 2. 准备文档和索引 corpus = [chunk["text"] for chunk in chunks] - tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus] - tokenized_corpus = [ - [word for word in doc if word not in self.hit_stopwords] - for doc in tokenized_corpus - ] + tokenized_corpus = [tokenize_text(doc, self.hit_stopwords) for doc in corpus] # 3. 构建 BM25 索引 bm25 = BM25Okapi(tokenized_corpus) # 4. 执行检索 - tokenized_query = list(jieba.cut(query)) - tokenized_query = [ - word for word in tokenized_query if word not in self.hit_stopwords - ] + tokenized_query = tokenize_text(query, self.hit_stopwords) scores = bm25.get_scores(tokenized_query) # 5. 排序并返回 Top-K diff --git a/astrbot/core/knowledge_base/retrieval/tokenizer.py b/astrbot/core/knowledge_base/retrieval/tokenizer.py new file mode 100644 index 000000000..1f4f07bb9 --- /dev/null +++ b/astrbot/core/knowledge_base/retrieval/tokenizer.py @@ -0,0 +1,39 @@ +"""Tokenization helpers shared by sparse retrieval indexes.""" + +import re +from pathlib import Path +from re import Pattern + +import jieba + +_TERM_PATTERN: Pattern[str] = re.compile(r"\w", re.UNICODE) + + +def load_stopwords(path: Path | str) -> set[str]: + with Path(path).open(encoding="utf-8") as f: + return {word.strip() for word in set(f.read().splitlines()) if word.strip()} + + +def tokenize_text(text: str, stopwords: set[str]) -> list[str]: + tokens = [] + for token in jieba.cut(text or ""): + token = token.strip() + if not token or token in stopwords: + continue + if not _TERM_PATTERN.search(token): + continue + tokens.append(token) + return tokens + + +def to_fts5_search_text(text: str, stopwords: set[str]) -> str: + return " ".join(tokenize_text(text, stopwords)) + + +def quote_fts5_token(token: str) -> str: + return '"' + token.replace('"', '""') + '"' + + +def build_fts5_or_query(tokens: list[str]) -> str: + quoted_tokens = [quote_fts5_token(token) for token in tokens if token] + return " OR ".join(quoted_tokens) diff --git a/tests/unit/test_document_storage_fts.py b/tests/unit/test_document_storage_fts.py new file mode 100644 index 000000000..753c371ef --- /dev/null +++ b/tests/unit/test_document_storage_fts.py @@ -0,0 +1,75 @@ +import pytest + +from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage + + +@pytest.mark.asyncio +async def test_document_storage_fts_insert_search_and_delete(tmp_path): + storage = DocumentStorage(str(tmp_path / "doc.db")) + await storage.initialize() + + assert storage.fts5_available is True + + await storage.insert_documents_batch( + doc_ids=["chunk-1", "chunk-2"], + texts=["AstrBot 知识库召回性能优化", "FAISS 向量检索"], + metadatas=[ + {"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0}, + {"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 1}, + ], + ) + + results = await storage.search_sparse(["知识库"], limit=10) + + assert results is not None + assert [result["doc_id"] for result in results] == ["chunk-1"] + + await storage.delete_document_by_doc_id("chunk-1") + results = await storage.search_sparse(["知识库"], limit=10) + + assert results == [] + + await storage.close() + + +@pytest.mark.asyncio +async def test_document_storage_fts_rebuilds_existing_documents(tmp_path): + storage = DocumentStorage(str(tmp_path / "doc.db")) + await storage.initialize() + + storage.fts5_available = False + await storage.insert_document( + doc_id="legacy-chunk", + text="legacy 知识库 文本", + metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0}, + ) + + storage.fts5_available = True + storage._fts_index_ready = False + + results = await storage.search_sparse(["知识库"], limit=10) + + assert results is not None + assert [result["doc_id"] for result in results] == ["legacy-chunk"] + + await storage.close() + + +@pytest.mark.asyncio +async def test_document_storage_fts_delete_skips_missing_fts_row(tmp_path): + storage = DocumentStorage(str(tmp_path / "doc.db")) + await storage.initialize() + + storage.fts5_available = False + await storage.insert_document( + doc_id="legacy-chunk", + text="legacy 知识库 文本", + metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0}, + ) + + storage.fts5_available = True + await storage.delete_document_by_doc_id("legacy-chunk") + + assert await storage.get_document_by_doc_id("legacy-chunk") is None + + await storage.close() diff --git a/tests/unit/test_sparse_retriever.py b/tests/unit/test_sparse_retriever.py new file mode 100644 index 000000000..11c491b4d --- /dev/null +++ b/tests/unit/test_sparse_retriever.py @@ -0,0 +1,93 @@ +import json +from types import SimpleNamespace + +import pytest + +from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever + + +def make_doc(chunk_id: str, text: str, chunk_index: int = 0) -> dict: + return { + "doc_id": chunk_id, + "text": text, + "metadata": json.dumps( + { + "chunk_index": chunk_index, + "kb_doc_id": f"doc-{chunk_index}", + "kb_id": "kb-1", + }, + ), + } + + +class FTSStorage: + def __init__(self): + self.search_sparse_calls = 0 + self.get_documents_calls = 0 + + async def search_sparse(self, query_tokens: list[str], limit: int): + self.search_sparse_calls += 1 + assert query_tokens == ["apple"] + assert limit == 1 + return [ + { + **make_doc("chunk-1", "apple banana", 0), + "score": -1.0, + }, + ] + + async def get_documents(self, *args, **kwargs): + self.get_documents_calls += 1 + return [] + + +class FallbackStorage: + def __init__(self): + self.search_sparse_calls = 0 + self.get_documents_calls = 0 + + async def search_sparse(self, query_tokens: list[str], limit: int): + self.search_sparse_calls += 1 + return None + + async def get_documents(self, metadata_filters: dict, limit: int | None, offset): + self.get_documents_calls += 1 + return [ + make_doc("chunk-1", "apple banana", 0), + make_doc("chunk-2", "orange pear", 1), + make_doc("chunk-3", "grape melon", 2), + ] + + +@pytest.mark.asyncio +async def test_sparse_retriever_uses_fts5_when_available(): + storage = FTSStorage() + vec_db = SimpleNamespace(document_storage=storage) + retriever = SparseRetriever(kb_db=None) + + results = await retriever.retrieve( + query="apple", + kb_ids=["kb-1"], + kb_options={"kb-1": {"vec_db": vec_db, "top_k_sparse": 1}}, + ) + + assert [result.chunk_id for result in results] == ["chunk-1"] + assert storage.search_sparse_calls == 1 + assert storage.get_documents_calls == 0 + + +@pytest.mark.asyncio +async def test_sparse_retriever_falls_back_to_bm25_when_fts5_is_unavailable(): + storage = FallbackStorage() + vec_db = SimpleNamespace(document_storage=storage) + retriever = SparseRetriever(kb_db=None) + + results = await retriever.retrieve( + query="apple", + kb_ids=["kb-1"], + kb_options={"kb-1": {"vec_db": vec_db, "top_k_sparse": 1}}, + ) + + assert [result.chunk_id for result in results] == ["chunk-1"] + assert storage.search_sparse_calls == 1 + assert storage.get_documents_calls == 1 From e98eb92b5f09097584b7828e0b0250d7cbd029cf Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:31:33 +0800 Subject: [PATCH 13/14] fix: prevent Telegram media group exceptions from being silently swallowed (#7537) * fix: prevent Telegram media group exceptions from being silently swallowed process_media_group() is invoked by APScheduler via add_job(). If convert_message() or handle_msg() raises (e.g. get_file() network timeout, file download failure), APScheduler catches the exception internally and only logs it through its own logger, which is often not configured in AstrBot. The result is that the media group silently disappears with no trace in the application logs. Two changes: - Wrap the body of process_media_group() in try/except so failures are logged through AstrBot's own logger with full traceback. - Register an EVENT_JOB_ERROR listener on the scheduler as a safety net, so any future scheduled job that throws will also surface in the logs. Fixes #7512 * ruff format --------- Co-authored-by: Soulter <905617992@qq.com> --- .../platform/sources/telegram/tg_adapter.py | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index a37e5cfd4..76863a194 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -6,6 +6,7 @@ import uuid from contextlib import suppress from typing import cast +from apscheduler.events import EVENT_JOB_ERROR from apscheduler.schedulers.asyncio import AsyncIOScheduler from telegram import BotCommand, Update from telegram.constants import ChatType @@ -80,6 +81,15 @@ class TelegramPlatformAdapter(Platform): self.last_command_hash = None self.scheduler = AsyncIOScheduler() + self.scheduler.add_listener( + lambda ev: logger.error( + "Scheduled job %s raised: %s", + ev.job_id, + ev.exception, + exc_info=ev.exception, + ), + EVENT_JOB_ERROR, + ) self._terminating = False self._loop: asyncio.AbstractEventLoop | None = None self._polling_recovery_requested = asyncio.Event() @@ -695,31 +705,36 @@ class TelegramPlatformAdapter(Platform): f"Processing media group {media_group_id}, total {len(updates_and_contexts)} items" ) - # Use the first update to create the base message (with reply, caption, etc.) - first_update, first_context = updates_and_contexts[0] - abm = await self.convert_message(first_update, first_context) + try: + # Use the first update to create the base message (with reply, caption, etc.) + first_update, first_context = updates_and_contexts[0] + abm = await self.convert_message(first_update, first_context) - if not abm: - logger.warning( - f"Failed to convert the first message of media group {media_group_id}" + if not abm: + logger.warning( + f"Failed to convert the first message of media group {media_group_id}" + ) + return + + # Add additional media from remaining updates by reusing convert_message + for update, context in updates_and_contexts[1:]: + # Convert the message but skip reply chains (get_reply=False) + extra = await self.convert_message(update, context, get_reply=False) + if not extra: + continue + + # Merge only the message components (keep base session/meta from first) + abm.message.extend(extra.message) + logger.debug( + f"Added {len(extra.message)} components to media group {media_group_id}" + ) + + # Process the merged message + await self.handle_msg(abm) + except Exception: + logger.error( + f"Failed to process media group {media_group_id}", exc_info=True ) - return - - # Add additional media from remaining updates by reusing convert_message - for update, context in updates_and_contexts[1:]: - # Convert the message but skip reply chains (get_reply=False) - extra = await self.convert_message(update, context, get_reply=False) - if not extra: - continue - - # Merge only the message components (keep base session/meta from first) - abm.message.extend(extra.message) - logger.debug( - f"Added {len(extra.message)} components to media group {media_group_id}" - ) - - # Process the merged message - await self.handle_msg(abm) async def handle_msg(self, message: AstrBotMessage) -> None: message_event = TelegramPlatformEvent( From 29a449f90db844eaf1a482b4bb55f09ec070bab0 Mon Sep 17 00:00:00 2001 From: bobo-xxx <111567133+bobo-xxx@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:37:36 +0800 Subject: [PATCH 14/14] fix: handle rate_limit_count=0 to prevent IndexError (#7635) * fix: handle rate_limit_count=0 to prevent IndexError * Update astrbot/core/pipeline/rate_limit_check/stage.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- astrbot/core/pipeline/rate_limit_check/stage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/astrbot/core/pipeline/rate_limit_check/stage.py b/astrbot/core/pipeline/rate_limit_check/stage.py index 392bceff3..d8b2b068a 100644 --- a/astrbot/core/pipeline/rate_limit_check/stage.py +++ b/astrbot/core/pipeline/rate_limit_check/stage.py @@ -63,6 +63,8 @@ class RateLimitStage(Stage): timestamps = self.event_timestamps[session_id] self._remove_expired_timestamps(timestamps, now) + if self.rate_limit_count <= 0: + break if len(timestamps) < self.rate_limit_count: timestamps.append(now) break