diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index f80e8588f..b0378532d 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -70,14 +70,14 @@ jobs:
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
- uses: docker/login-action@v4.0.0
+ uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
- uses: docker/login-action@v4.0.0
+ uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -169,14 +169,14 @@ jobs:
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
- uses: docker/login-action@v4.0.0
+ uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
- uses: docker/login-action@v4.0.0
+ uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
diff --git a/README.md b/README.md
index d3c30ccda..34b75b468 100644
--- a/README.md
+++ b/README.md
@@ -157,6 +157,7 @@ Connect AstrBot to your favorite chat platform.
| LINE | Official |
| Satori | Official |
| Misskey | Official |
+| Mattermost | Official |
| WhatsApp (Coming Soon) | Official |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
diff --git a/astrbot/builtin_stars/web_searcher/metadata.yaml b/astrbot/builtin_stars/web_searcher/metadata.yaml
deleted file mode 100644
index fc5309787..000000000
--- a/astrbot/builtin_stars/web_searcher/metadata.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: astrbot-web-searcher
-desc: 让 LLM 具有网页检索能力
-author: Soulter
-version: 1.14.514
\ No newline at end of file
diff --git a/astrbot/core/astr_agent_hooks.py b/astrbot/core/astr_agent_hooks.py
index a5e96f5e7..ca6dc8f44 100644
--- a/astrbot/core/astr_agent_hooks.py
+++ b/astrbot/core/astr_agent_hooks.py
@@ -135,7 +135,13 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
- and tool.name in ["web_search_tavily", "web_search_bocha"]
+ and tool.name
+ in [
+ "web_search_baidu",
+ "web_search_tavily",
+ "web_search_bocha",
+ "web_search_brave",
+ ]
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
diff --git a/astrbot/core/backup/constants.py b/astrbot/core/backup/constants.py
index 338c7f57b..4782b54e5 100644
--- a/astrbot/core/backup/constants.py
+++ b/astrbot/core/backup/constants.py
@@ -7,6 +7,7 @@ from sqlmodel import SQLModel
from astrbot.core.db.po import (
Attachment,
+ ChatUIProject,
CommandConfig,
CommandConflict,
ConversationV2,
@@ -16,6 +17,7 @@ from astrbot.core.db.po import (
PlatformSession,
PlatformStat,
Preference,
+ SessionProjectRelation,
)
from astrbot.core.knowledge_base.models import (
KBDocument,
@@ -44,6 +46,8 @@ MAIN_DB_MODELS: dict[str, type[SQLModel]] = {
"preferences": Preference,
"platform_message_history": PlatformMessageHistory,
"platform_sessions": PlatformSession,
+ "chatui_projects": ChatUIProject,
+ "session_project_relations": SessionProjectRelation,
"attachments": Attachment,
"command_configs": CommandConfig,
"command_conflicts": CommandConflict,
diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py
index 21d580b43..babcfa259 100644
--- a/astrbot/core/knowledge_base/kb_db_sqlite.py
+++ b/astrbot/core/knowledge_base/kb_db_sqlite.py
@@ -1,12 +1,12 @@
from contextlib import asynccontextmanager
from pathlib import Path
+from typing import TYPE_CHECKING
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlmodel import col, desc
from astrbot.core import logger
-from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
from astrbot.core.knowledge_base.models import (
BaseKBModel,
KBDocument,
@@ -15,6 +15,9 @@ from astrbot.core.knowledge_base.models import (
)
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
+if TYPE_CHECKING:
+ from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
+
class KBSQLiteDatabase:
def __init__(self, db_path: str | None = None) -> None:
@@ -295,7 +298,7 @@ class KBSQLiteDatabase:
return metadata_map
- async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
+ async def delete_document_by_id(self, doc_id: str, vec_db: "FaissVecDB") -> None:
"""删除单个文档及其相关数据"""
# 在知识库表中删除
async with self.get_db() as session, session.begin():
@@ -323,7 +326,7 @@ class KBSQLiteDatabase:
result = await session.execute(stmt)
return result.scalar_one_or_none()
- async def update_kb_stats(self, kb_id: str, vec_db: FaissVecDB) -> None:
+ async def update_kb_stats(self, kb_id: str, vec_db: "FaissVecDB") -> None:
"""更新知识库统计信息"""
chunk_cnt = await vec_db.count_documents()
diff --git a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py
index d453251d1..9d485dab2 100644
--- a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py
+++ b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py
@@ -6,13 +6,16 @@
import json
import os
from dataclasses import dataclass
+from typing import TYPE_CHECKING
import jieba
from rank_bm25 import BM25Okapi
-from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
+if TYPE_CHECKING:
+ from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
+
@dataclass
class SparseResult:
@@ -73,7 +76,7 @@ class SparseRetriever:
top_k_sparse = 0
chunks = []
for kb_id in kb_ids:
- vec_db: FaissVecDB = kb_options.get(kb_id, {}).get("vec_db")
+ vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db")
if not vec_db:
continue
result = await vec_db.document_storage.get_documents(
diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py
index 32a3bc8cc..b27735265 100644
--- a/astrbot/core/platform/manager.py
+++ b/astrbot/core/platform/manager.py
@@ -212,6 +212,10 @@ class PlatformManager:
from .sources.kook.kook_adapter import ( # noqa: F401
KookPlatformAdapter,
)
+ case "mattermost":
+ from .sources.mattermost.mattermost_adapter import (
+ MattermostPlatformAdapter, # noqa: F401
+ )
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
diff --git a/astrbot/core/platform/sources/mattermost/__init__.py b/astrbot/core/platform/sources/mattermost/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py
new file mode 100644
index 000000000..88619e215
--- /dev/null
+++ b/astrbot/core/platform/sources/mattermost/client.py
@@ -0,0 +1,260 @@
+import asyncio
+import json
+import mimetypes
+from pathlib import Path
+from typing import Any
+
+import aiohttp
+
+from astrbot.api import logger
+from astrbot.api.event import MessageChain
+from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
+from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+
+
+class MattermostClient:
+ def __init__(self, base_url: str, token: str) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.token = token
+ self._session: aiohttp.ClientSession | None = None
+
+ async def ensure_session(self) -> aiohttp.ClientSession:
+ if self._session is None or self._session.closed:
+ self._session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30),
+ )
+ return self._session
+
+ async def close(self) -> None:
+ if self._session and not self._session.closed:
+ await self._session.close()
+
+ def _headers(self) -> dict[str, str]:
+ return {
+ "Authorization": f"Bearer {self.token}",
+ "Content-Type": "application/json",
+ }
+
+ def _auth_headers(self) -> dict[str, str]:
+ return {"Authorization": f"Bearer {self.token}"}
+
+ async def get_json(self, path: str) -> dict[str, Any]:
+ session = await self.ensure_session()
+ url = f"{self.base_url}/api/v4/{path.lstrip('/')}"
+ async with session.get(url, headers=self._headers()) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ raise RuntimeError(
+ f"Mattermost GET {path} failed: {resp.status} {body}"
+ )
+ data = await resp.json()
+ if not isinstance(data, dict):
+ raise RuntimeError(f"Mattermost GET {path} returned non-object JSON")
+ return data
+
+ async def post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
+ session = await self.ensure_session()
+ url = f"{self.base_url}/api/v4/{path.lstrip('/')}"
+ async with session.post(url, headers=self._headers(), json=payload) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ raise RuntimeError(
+ f"Mattermost POST {path} failed: {resp.status} {body}"
+ )
+ data = await resp.json()
+ if not isinstance(data, dict):
+ raise RuntimeError(f"Mattermost POST {path} returned non-object JSON")
+ return data
+
+ async def get_me(self) -> dict[str, Any]:
+ return await self.get_json("users/me")
+
+ async def get_channel(self, channel_id: str) -> dict[str, Any]:
+ return await self.get_json(f"channels/{channel_id}")
+
+ async def get_file_info(self, file_id: str) -> dict[str, Any]:
+ return await self.get_json(f"files/{file_id}/info")
+
+ async def download_file(self, file_id: str) -> bytes:
+ session = await self.ensure_session()
+ url = f"{self.base_url}/api/v4/files/{file_id}"
+ async with session.get(url, headers=self._auth_headers()) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ raise RuntimeError(
+ f"Mattermost download file {file_id} failed: {resp.status} {body}"
+ )
+ return await resp.read()
+
+ async def upload_file(
+ self,
+ channel_id: str,
+ file_bytes: bytes,
+ filename: str,
+ content_type: str,
+ ) -> str:
+ session = await self.ensure_session()
+ url = f"{self.base_url}/api/v4/files"
+ form = aiohttp.FormData()
+ form.add_field("channel_id", channel_id)
+ form.add_field(
+ "files",
+ file_bytes,
+ filename=filename,
+ content_type=content_type,
+ )
+ async with session.post(url, headers=self._auth_headers(), data=form) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ raise RuntimeError(
+ f"Mattermost upload file failed: {resp.status} {body}"
+ )
+ data = await resp.json()
+ file_infos = data.get("file_infos", [])
+ if not file_infos:
+ raise RuntimeError("Mattermost upload file returned no file_infos")
+ file_id = file_infos[0].get("id", "")
+ if not file_id:
+ raise RuntimeError("Mattermost upload file returned empty file id")
+ return str(file_id)
+
+ async def create_post(
+ self,
+ channel_id: str,
+ message: str,
+ *,
+ file_ids: list[str] | None = None,
+ root_id: str | None = None,
+ ) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "channel_id": channel_id,
+ "message": message,
+ }
+ if file_ids:
+ payload["file_ids"] = file_ids
+ if root_id:
+ payload["root_id"] = root_id
+ return await self.post_json("posts", payload)
+
+ async def ws_connect(self) -> aiohttp.ClientWebSocketResponse:
+ session = await self.ensure_session()
+ ws_url = self.base_url.replace("https://", "wss://", 1).replace(
+ "http://", "ws://", 1
+ )
+ ws_url = f"{ws_url}/api/v4/websocket"
+ return await session.ws_connect(ws_url, heartbeat=30.0)
+
+ async def send_message_chain(
+ self,
+ channel_id: str,
+ message_chain: MessageChain,
+ ) -> dict[str, Any]:
+ text_parts: list[str] = []
+ file_ids: list[str] = []
+ root_id: str | None = None
+
+ for segment in message_chain.chain:
+ if isinstance(segment, Plain):
+ text_parts.append(segment.text)
+ elif isinstance(segment, At):
+ mention_name = str(segment.name or segment.qq or "").strip()
+ if mention_name:
+ text_parts.append(f"@{mention_name}")
+ elif isinstance(segment, Reply):
+ if segment.id:
+ root_id = str(segment.id)
+ elif isinstance(segment, Image):
+ path = await segment.convert_to_file_path()
+ file_path = Path(path)
+ file_bytes = await asyncio.to_thread(file_path.read_bytes)
+ file_ids.append(
+ await self.upload_file(
+ channel_id,
+ file_bytes,
+ file_path.name,
+ mimetypes.guess_type(file_path.name)[0] or "image/jpeg",
+ )
+ )
+ elif isinstance(segment, (File, Record, Video)):
+ if isinstance(segment, File):
+ path = await segment.get_file()
+ filename = segment.name or Path(path).name
+ else:
+ path = await segment.convert_to_file_path()
+ filename = Path(path).name
+ file_path = Path(path)
+ file_bytes = await asyncio.to_thread(file_path.read_bytes)
+ file_ids.append(
+ await self.upload_file(
+ channel_id,
+ file_bytes,
+ filename,
+ mimetypes.guess_type(filename)[0] or "application/octet-stream",
+ )
+ )
+ else:
+ logger.debug(
+ "Mattermost send_message_chain skipped unsupported segment: %s",
+ segment.type,
+ )
+
+ return await self.create_post(
+ channel_id,
+ "".join(text_parts).strip(),
+ file_ids=file_ids or None,
+ root_id=root_id,
+ )
+
+ async def parse_post_attachments(
+ self,
+ file_ids: list[str],
+ ) -> tuple[list[Any], list[str]]:
+ components: list[Any] = []
+ temp_paths: list[str] = []
+
+ for file_id in file_ids:
+ try:
+ info = await self.get_file_info(file_id)
+ file_bytes = await self.download_file(file_id)
+ except Exception as exc:
+ logger.warning(
+ "Mattermost fetch attachment failed %s: %s", file_id, exc
+ )
+ continue
+
+ filename = str(info.get("name") or f"file_{file_id}")
+ mime_type = str(info.get("mime_type") or "application/octet-stream")
+ suffix = Path(filename).suffix
+ file_path = Path(get_astrbot_temp_path()) / f"mattermost_{file_id}{suffix}"
+ try:
+ await asyncio.to_thread(file_path.write_bytes, file_bytes)
+ except OSError as exc:
+ logger.warning(
+ "Mattermost write attachment failed %s -> %s: %s",
+ file_id,
+ file_path,
+ exc,
+ )
+ continue
+ temp_paths.append(str(file_path))
+
+ if mime_type.startswith("image/"):
+ components.append(Image.fromFileSystem(str(file_path)))
+ elif mime_type.startswith("audio/"):
+ components.append(Record.fromFileSystem(str(file_path)))
+ elif mime_type.startswith("video/"):
+ components.append(Video.fromFileSystem(str(file_path)))
+ else:
+ components.append(File(name=filename, file=str(file_path)))
+
+ return components, temp_paths
+
+ @staticmethod
+ def parse_websocket_post(raw_post: str) -> dict[str, Any] | None:
+ try:
+ parsed = json.loads(raw_post)
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ return parsed
diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py
new file mode 100644
index 000000000..583e0d0af
--- /dev/null
+++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py
@@ -0,0 +1,323 @@
+import asyncio
+import json
+import re
+import time
+from collections import deque
+from typing import Any, cast
+
+import aiohttp
+
+from astrbot.api import logger
+from astrbot.api.event import MessageChain
+from astrbot.api.message_components import At, Plain
+from astrbot.api.platform import (
+ AstrBotMessage,
+ MessageMember,
+ MessageType,
+ Platform,
+ PlatformMetadata,
+)
+from astrbot.core.platform.astr_message_event import MessageSesion
+
+from ...register import register_platform_adapter
+from .client import MattermostClient
+from .mattermost_event import MattermostMessageEvent
+
+
+@register_platform_adapter(
+ "mattermost",
+ "Mattermost 平台适配器",
+ support_streaming_message=False,
+)
+class MattermostPlatformAdapter(Platform):
+ def __init__(
+ self,
+ platform_config: dict,
+ platform_settings: dict,
+ event_queue: asyncio.Queue,
+ ) -> None:
+ super().__init__(platform_config, event_queue)
+ self.settings = platform_settings
+ self.base_url = str(platform_config.get("mattermost_url", "")).rstrip("/")
+ self.bot_token = str(platform_config.get("mattermost_bot_token", "")).strip()
+ self.reconnect_delay = float(
+ platform_config.get("mattermost_reconnect_delay", 5.0)
+ )
+
+ if not self.base_url:
+ raise ValueError("Mattermost URL 是必需的")
+ if not self.bot_token:
+ raise ValueError("Mattermost bot token 是必需的")
+
+ self.client = MattermostClient(self.base_url, self.bot_token)
+ self.metadata = PlatformMetadata(
+ name="mattermost",
+ description="Mattermost 平台适配器",
+ id=cast(str, self.config.get("id", "mattermost")),
+ support_streaming_message=False,
+ )
+ self.bot_self_id = ""
+ self.bot_username = ""
+ self._mention_pattern: re.Pattern[str] | None = None
+ self._running = True
+ self._seen_post_ids: dict[str, float] = {}
+ self._seen_post_queue: deque[tuple[str, float]] = deque()
+ self._dedup_ttl = 300.0
+
+ async def send_by_session(
+ self,
+ session: MessageSesion,
+ message_chain: MessageChain,
+ ) -> None:
+ await self.client.send_message_chain(session.session_id, message_chain)
+ await super().send_by_session(session, message_chain)
+
+ def meta(self) -> PlatformMetadata:
+ return self.metadata
+
+ async def run(self) -> None:
+ me = await self.client.get_me()
+ self.bot_self_id = str(me.get("id", ""))
+ self.bot_username = str(me.get("username", ""))
+ self._mention_pattern = self._build_mention_pattern(self.bot_username)
+ if not self.bot_self_id:
+ raise RuntimeError("Mattermost auth succeeded but returned empty user id")
+
+ logger.info(
+ "Mattermost auth test OK. Bot: @%s (%s)",
+ self.bot_username,
+ self.bot_self_id,
+ )
+
+ while self._running:
+ try:
+ await self._ws_connect_and_listen()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ if not self._running:
+ break
+ logger.warning(
+ "Mattermost websocket disconnected: %s. Retrying in %.1fs.",
+ exc,
+ self.reconnect_delay,
+ )
+ await asyncio.sleep(self.reconnect_delay)
+
+ async def _ws_connect_and_listen(self) -> None:
+ ws = await self.client.ws_connect()
+ try:
+ await ws.send_json(
+ {
+ "seq": 1,
+ "action": "authentication_challenge",
+ "data": {"token": self.bot_token},
+ }
+ )
+
+ async for message in ws:
+ if message.type != aiohttp.WSMsgType.TEXT:
+ if message.type in {
+ aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSED,
+ aiohttp.WSMsgType.CLOSING,
+ aiohttp.WSMsgType.ERROR,
+ }:
+ break
+ continue
+
+ try:
+ payload = json.loads(message.data)
+ except json.JSONDecodeError:
+ logger.debug(
+ "Mattermost websocket received non-JSON text frame: %r",
+ message.data,
+ )
+ continue
+ if isinstance(payload, dict):
+ await self._handle_ws_event(payload)
+ finally:
+ await ws.close()
+
+ async def _handle_ws_event(self, payload: dict[str, Any]) -> None:
+ if payload.get("event") != "posted":
+ return
+
+ data = payload.get("data")
+ if not isinstance(data, dict):
+ return
+
+ raw_post = data.get("post")
+ if not isinstance(raw_post, str):
+ return
+
+ post = self.client.parse_websocket_post(raw_post)
+ if not post:
+ return
+
+ user_id = str(post.get("user_id", ""))
+ if not user_id or user_id == self.bot_self_id:
+ return
+ if post.get("type"):
+ return
+
+ post_id = str(post.get("id", ""))
+ if post_id and self._is_duplicate_post(post_id):
+ return
+
+ abm = await self.convert_message(post=post, data=data)
+ if abm is not None:
+ await self.handle_msg(abm)
+
+ def _is_duplicate_post(self, post_id: str) -> bool:
+ now = time.monotonic()
+ self._prune_seen_posts(now)
+ if post_id in self._seen_post_ids:
+ return True
+ self._seen_post_ids[post_id] = now
+ self._seen_post_queue.append((post_id, now))
+ return False
+
+ def _prune_seen_posts(self, now: float) -> None:
+ while self._seen_post_queue:
+ queued_post_id, seen_at = self._seen_post_queue[0]
+ if now - seen_at <= self._dedup_ttl:
+ break
+ self._seen_post_queue.popleft()
+ current_seen_at = self._seen_post_ids.get(queued_post_id)
+ if current_seen_at == seen_at:
+ del self._seen_post_ids[queued_post_id]
+
+ async def convert_message(
+ self,
+ *,
+ post: dict[str, Any],
+ data: dict[str, Any],
+ ) -> AstrBotMessage | None:
+ channel_id = str(post.get("channel_id", "") or "")
+ if not channel_id:
+ return None
+
+ channel_type = str(data.get("channel_type", "O") or "O")
+ sender_id = str(post.get("user_id", "") or "")
+ sender_name = str(data.get("sender_name", "") or sender_id).lstrip("@")
+ message_text = str(post.get("message", "") or "")
+ file_ids = [
+ str(file_id)
+ for file_id in (post.get("file_ids") or [])
+ if str(file_id).strip()
+ ]
+
+ abm = AstrBotMessage()
+ abm.self_id = self.bot_self_id
+ abm.sender = MessageMember(user_id=sender_id, nickname=sender_name)
+ abm.session_id = channel_id
+ abm.message_id = str(post.get("id") or channel_id)
+ abm.raw_message = post
+ abm.timestamp = self._parse_timestamp(post.get("create_at"))
+ abm.message = self._parse_text_components(message_text)
+
+ if channel_type == "D":
+ abm.type = MessageType.FRIEND_MESSAGE
+ else:
+ abm.type = MessageType.GROUP_MESSAGE
+ abm.group_id = channel_id
+
+ if file_ids:
+ (
+ attachment_components,
+ temp_paths,
+ ) = await self.client.parse_post_attachments(file_ids)
+ abm.message.extend(attachment_components)
+ setattr(abm, "temporary_file_paths", temp_paths)
+
+ abm.message_str = self._build_message_str(
+ abm.message,
+ message_text,
+ self.bot_self_id,
+ )
+ return abm
+
+ def _parse_text_components(self, message_text: str) -> list[Any]:
+ if not message_text:
+ return []
+
+ components: list[Any] = []
+ if not self.bot_username:
+ return [Plain(message_text)]
+
+ mention_pattern = self._mention_pattern
+ if mention_pattern is None:
+ mention_pattern = self._build_mention_pattern(self.bot_username)
+ if mention_pattern is None:
+ return [Plain(message_text)]
+ last_end = 0
+
+ for match in mention_pattern.finditer(message_text):
+ if match.start() > last_end:
+ components.append(Plain(message_text[last_end : match.start()]))
+ components.append(At(qq=self.bot_self_id, name=self.bot_username))
+ last_end = match.end()
+
+ if last_end < len(message_text):
+ components.append(Plain(message_text[last_end:]))
+
+ if not components:
+ components.append(Plain(message_text))
+ return components
+
+ @staticmethod
+ def _build_mention_pattern(bot_username: str) -> re.Pattern[str] | None:
+ if not bot_username:
+ return None
+ return re.compile(
+ rf"(? str:
+ text_parts: list[str] = []
+ leading_self_mention_skipped = False
+
+ for component in components:
+ if isinstance(component, Plain):
+ text_parts.append(component.text)
+ elif isinstance(component, At):
+ is_self_mention = str(component.qq) == self_id
+ if not leading_self_mention_skipped and is_self_mention:
+ leading_self_mention_skipped = True
+ if not text_parts or not "".join(text_parts).strip():
+ continue
+ mention_name = str(component.name or component.qq or "").strip()
+ if mention_name:
+ text_parts.append(f"@{mention_name}")
+ message_str = "".join(text_parts).strip()
+ return message_str or fallback.strip()
+
+ @staticmethod
+ def _parse_timestamp(raw_value: Any) -> int:
+ if isinstance(raw_value, int):
+ return raw_value // 1000 if raw_value > 1_000_000_000_000 else raw_value
+ return int(time.time())
+
+ async def handle_msg(self, message: AstrBotMessage) -> None:
+ message_event = MattermostMessageEvent(
+ message_str=message.message_str,
+ message_obj=message,
+ platform_meta=self.meta(),
+ session_id=message.session_id,
+ client=self.client,
+ )
+ self.commit_event(message_event)
+
+ async def terminate(self) -> None:
+ self._running = False
+ await self.client.close()
+
+ def get_client(self) -> MattermostClient:
+ return self.client
diff --git a/astrbot/core/platform/sources/mattermost/mattermost_event.py b/astrbot/core/platform/sources/mattermost/mattermost_event.py
new file mode 100644
index 000000000..5faaf7134
--- /dev/null
+++ b/astrbot/core/platform/sources/mattermost/mattermost_event.py
@@ -0,0 +1,88 @@
+import asyncio
+import re
+from collections.abc import AsyncGenerator
+
+from astrbot.api.event import AstrMessageEvent, MessageChain
+from astrbot.api.message_components import Plain
+from astrbot.api.platform import Group, MessageMember
+
+from .client import MattermostClient
+
+
+class MattermostMessageEvent(AstrMessageEvent):
+ _FALLBACK_SENTENCE_PATTERN = re.compile(r"[^。?!~…]+[。?!~…]+")
+
+ def __init__(
+ self,
+ message_str,
+ message_obj,
+ platform_meta,
+ session_id,
+ client: MattermostClient,
+ ) -> None:
+ super().__init__(message_str, message_obj, platform_meta, session_id)
+ self.client = client
+ for path in getattr(message_obj, "temporary_file_paths", []):
+ self.track_temporary_local_file(path)
+
+ async def send(self, message: MessageChain) -> None:
+ await self.client.send_message_chain(self.get_session_id(), message)
+ await super().send(message)
+
+ async def send_streaming(
+ self,
+ generator: AsyncGenerator,
+ use_fallback: bool = False,
+ ) -> None:
+ await super().send_streaming(generator, use_fallback)
+
+ if not use_fallback:
+ message_buffer: MessageChain | None = None
+ async for chain in generator:
+ if not message_buffer:
+ message_buffer = chain
+ else:
+ message_buffer.chain.extend(chain.chain)
+ if not message_buffer:
+ return None
+ message_buffer.squash_plain()
+ await self.send(message_buffer)
+ return None
+
+ text_buffer = ""
+
+ async for chain in generator:
+ if isinstance(chain, MessageChain):
+ for comp in chain.chain:
+ if isinstance(comp, Plain):
+ text_buffer += comp.text
+ if any(p in text_buffer for p in "。?!~…"):
+ text_buffer = await self.process_buffer(
+ text_buffer,
+ self._FALLBACK_SENTENCE_PATTERN,
+ )
+ else:
+ await self.send(MessageChain(chain=[comp]))
+ await asyncio.sleep(1.5)
+
+ if text_buffer.strip():
+ await self.send(MessageChain([Plain(text_buffer)]))
+ return None
+
+ async def get_group(self, group_id=None, **kwargs):
+ channel_id = group_id or self.get_group_id()
+ if not channel_id:
+ return None
+ channel = await self.client.get_channel(channel_id)
+ return Group(
+ group_id=channel_id,
+ group_name=channel.get("display_name") or channel.get("name") or channel_id,
+ group_owner="",
+ group_admins=[],
+ members=[
+ MessageMember(
+ user_id=self.get_sender_id(),
+ nickname=self.get_sender_name(),
+ )
+ ],
+ )
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 968434fd5..e75c4b7e3 100644
--- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py
+++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py
@@ -159,4 +159,4 @@ class QQOfficialWebhookPlatformAdapter(Platform):
f"Exception occurred during QQOfficialWebhook server shutdown: {exc}",
exc_info=True,
)
- logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭")
+ logger.info("QQ 机器人官方 API 适配器已经被关闭")
diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py
index ece8d951d..8f37f9f6a 100644
--- a/astrbot/core/platform/sources/telegram/tg_adapter.py
+++ b/astrbot/core/platform/sources/telegram/tg_adapter.py
@@ -197,7 +197,10 @@ class TelegramPlatformAdapter(Platform):
skip_commands = {"start"}
for handler_md in star_handlers_registry:
handler_metadata = handler_md
- if not star_map[handler_metadata.handler_module_path].activated:
+ if (
+ handler_metadata.handler_module_path not in star_map
+ or not star_map[handler_metadata.handler_module_path].activated
+ ):
continue
if not handler_metadata.enabled:
continue
diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py
index 045f0dc42..e747d3c2f 100644
--- a/astrbot/core/platform/sources/telegram/tg_event.py
+++ b/astrbot/core/platform/sources/telegram/tg_event.py
@@ -333,6 +333,9 @@ class TelegramPlatformEvent(AstrMessageEvent):
message_thread_id: 可选,目标消息线程 ID
parse_mode: 可选,消息文本的解析模式
"""
+ if not text or not text.strip():
+ return
+
kwargs: dict[str, Any] = {}
if message_thread_id:
kwargs["message_thread_id"] = int(message_thread_id)
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 914bd8cd3..d026e2cf6 100644
--- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
+++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
@@ -694,7 +694,7 @@ class WeixinOCAdapter(Platform):
context_token = self._context_tokens.get(user_id)
if not context_token:
logger.warning(
- "weixin_oc(%s): context token missing for %s, skip send",
+ "weixin_oc(%s): context token missing for %s, skip send. You should send one message to refresh context_token.",
self.meta().id,
user_id,
)
diff --git a/astrbot/core/tools/knowledge_base_tools.py b/astrbot/core/tools/knowledge_base_tools.py
new file mode 100644
index 000000000..e27a883d4
--- /dev/null
+++ b/astrbot/core/tools/knowledge_base_tools.py
@@ -0,0 +1,129 @@
+from pydantic import Field
+from pydantic.dataclasses import dataclass
+
+from astrbot.api import logger, sp
+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.knowledge_base.kb_helper import KBHelper
+from astrbot.core.star.context import Context
+from astrbot.core.tools.registry import builtin_tool
+
+
+def check_all_kb(kb_list: list[KBHelper | None]) -> bool:
+ """检查是否所有的知识库都为空"""
+ return not any(
+ kb and (kb.kb.doc_count != 0 or kb.kb.chunk_count != 0) for kb in kb_list
+ )
+
+
+async def retrieve_knowledge_base(
+ query: str,
+ umo: str,
+ context: Context,
+) -> str | None:
+ """Retrieve knowledge base context for the given query."""
+ kb_mgr = context.kb_manager
+ config = context.get_config(umo=umo)
+
+ session_config = await sp.session_get(umo, "kb_config", default={})
+ if session_config and "kb_ids" in session_config:
+ kb_ids = session_config.get("kb_ids", [])
+ if not kb_ids:
+ logger.info(f"[知识库] 会话 {umo} 已被配置为不使用知识库")
+ return None
+
+ top_k = session_config.get("top_k", 5)
+ kb_names = []
+ invalid_kb_ids = []
+ for kb_id in kb_ids:
+ kb_helper = await kb_mgr.get_kb(kb_id)
+ if kb_helper:
+ kb_names.append(kb_helper.kb.kb_name)
+ else:
+ logger.warning(f"[知识库] 知识库不存在或未加载: {kb_id}")
+ invalid_kb_ids.append(kb_id)
+
+ if invalid_kb_ids:
+ logger.warning(
+ f"[知识库] 会话 {umo} 配置的以下知识库无效: {invalid_kb_ids}",
+ )
+ if not kb_names:
+ return None
+ logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
+ else:
+ kb_names = config.get("kb_names", [])
+ top_k = config.get("kb_final_top_k", 5)
+ logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
+
+ top_k_fusion = config.get("kb_fusion_top_k", 20)
+ if not kb_names:
+ return None
+
+ all_kbs = [await kb_mgr.get_kb_by_name(kb) for kb in kb_names]
+ if check_all_kb(all_kbs):
+ logger.debug("所配置的所有知识库全为空,跳过检索过程")
+ return None
+
+ logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
+ kb_context = await kb_mgr.retrieve(
+ query=query,
+ kb_names=kb_names,
+ top_k_fusion=top_k_fusion,
+ top_m_final=top_k,
+ )
+ if not kb_context:
+ return None
+
+ formatted = kb_context.get("context_text", "")
+ if formatted:
+ results = kb_context.get("results", [])
+ logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
+ return formatted
+ return None
+
+
+@builtin_tool
+@dataclass
+class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
+ name: str = "astr_kb_search"
+ description: str = (
+ "Query the knowledge base for facts or relevant context. "
+ "Use this tool when the user's question requires factual information, "
+ "definitions, background knowledge, or previously indexed content. "
+ "Only send short keywords or a concise question as the query."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "A concise keyword query for the knowledge base.",
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(
+ self, context: ContextWrapper[AstrAgentContext], **kwargs
+ ) -> ToolExecResult:
+ query = kwargs.get("query", "")
+ if not query:
+ return "error: Query parameter is empty."
+ result = await retrieve_knowledge_base(
+ query=query,
+ umo=context.context.event.unified_msg_origin,
+ context=context.context.context,
+ )
+ if not result:
+ return "No relevant knowledge found."
+ return result
+
+
+__all__ = [
+ "KnowledgeBaseQueryTool",
+ "check_all_kb",
+ "retrieve_knowledge_base",
+]
diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py
new file mode 100644
index 000000000..020c1ad5a
--- /dev/null
+++ b/astrbot/core/tools/message_tools.py
@@ -0,0 +1,210 @@
+import json
+import os
+import shlex
+import uuid
+
+from pydantic import Field
+from pydantic.dataclasses import dataclass
+
+import astrbot.core.message.components as Comp
+from astrbot.api import logger
+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.computer.computer_client import get_booter
+from astrbot.core.message.message_event_result import MessageChain
+from astrbot.core.platform.message_session import MessageSession
+from astrbot.core.tools.registry import builtin_tool
+from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+
+
+@builtin_tool
+@dataclass
+class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
+ name: str = "send_message_to_user"
+ description: str = (
+ "Send message to the user. "
+ "Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
+ "Use this tool to send media files (`image`, `record`, `video`, `file`), "
+ "or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "messages": {
+ "type": "array",
+ "description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": (
+ "Component type. One of: "
+ "plain, image, record, video, file, mention_user. Record is voice message."
+ ),
+ },
+ "text": {
+ "type": "string",
+ "description": "Text content for `plain` type.",
+ },
+ "path": {
+ "type": "string",
+ "description": "File path for `image`, `record`, `video`, or `file` types. Both local path and sandbox path are supported.",
+ },
+ "url": {
+ "type": "string",
+ "description": "URL for `image`, `record`, `video`, or `file` types.",
+ },
+ "mention_user_id": {
+ "type": "string",
+ "description": "User ID to mention for `mention_user` type.",
+ },
+ },
+ "required": ["type"],
+ },
+ },
+ "session": {
+ "type": "string",
+ "description": "Optional. Target session string. Defaults to current session.",
+ },
+ },
+ "required": ["messages"],
+ }
+ )
+
+ async def _resolve_path_from_sandbox(
+ self, context: ContextWrapper[AstrAgentContext], path: str
+ ) -> tuple[str, bool]:
+ if os.path.exists(path):
+ return path, False
+
+ try:
+ sb = await get_booter(
+ context.context.context,
+ context.context.event.unified_msg_origin,
+ )
+ quoted_path = shlex.quote(path)
+ result = await sb.shell.exec(f"test -f {quoted_path} && echo '_&exists_'")
+ if "_&exists_" in json.dumps(result):
+ name = os.path.basename(path)
+ local_path = os.path.join(
+ get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
+ )
+ await sb.download_file(path, local_path)
+ logger.info(f"Downloaded file from sandbox: {path} -> {local_path}")
+ return local_path, True
+ except Exception as exc:
+ logger.warning(f"Failed to check/download file from sandbox: {exc}")
+
+ return path, False
+
+ async def call(
+ self, context: ContextWrapper[AstrAgentContext], **kwargs
+ ) -> ToolExecResult:
+ session = kwargs.get("session") or context.context.event.unified_msg_origin
+ messages = kwargs.get("messages")
+ if not isinstance(messages, list) or not messages:
+ return "error: messages parameter is empty or invalid."
+
+ components: list[Comp.BaseMessageComponent] = []
+ for idx, msg in enumerate(messages):
+ if not isinstance(msg, dict):
+ return f"error: messages[{idx}] should be an object."
+
+ msg_type = str(msg.get("type", "")).lower()
+ if not msg_type:
+ return f"error: messages[{idx}].type is required."
+
+ try:
+ if msg_type == "plain":
+ text = str(msg.get("text", "")).strip()
+ if not text:
+ return f"error: messages[{idx}].text is required for plain component."
+ components.append(Comp.Plain(text=text))
+ elif msg_type == "image":
+ path = msg.get("path")
+ url = msg.get("url")
+ if path:
+ local_path, _ = await self._resolve_path_from_sandbox(
+ context, path
+ )
+ components.append(Comp.Image.fromFileSystem(path=local_path))
+ elif url:
+ components.append(Comp.Image.fromURL(url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for image component."
+ elif msg_type == "record":
+ path = msg.get("path")
+ url = msg.get("url")
+ if path:
+ local_path, _ = await self._resolve_path_from_sandbox(
+ context, path
+ )
+ components.append(Comp.Record.fromFileSystem(path=local_path))
+ elif url:
+ components.append(Comp.Record.fromURL(url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for record component."
+ elif msg_type == "video":
+ path = msg.get("path")
+ url = msg.get("url")
+ if path:
+ local_path, _ = await self._resolve_path_from_sandbox(
+ context, path
+ )
+ components.append(Comp.Video.fromFileSystem(path=local_path))
+ elif url:
+ components.append(Comp.Video.fromURL(url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for video component."
+ elif msg_type == "file":
+ path = msg.get("path")
+ url = msg.get("url")
+ name = (
+ msg.get("text")
+ or (os.path.basename(path) if path else "")
+ or (os.path.basename(url) if url else "")
+ or "file"
+ )
+ if path:
+ local_path, _ = await self._resolve_path_from_sandbox(
+ context, path
+ )
+ components.append(Comp.File(name=name, file=local_path))
+ elif url:
+ components.append(Comp.File(name=name, url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for file component."
+ elif msg_type == "mention_user":
+ mention_user_id = msg.get("mention_user_id")
+ if not mention_user_id:
+ return f"error: messages[{idx}].mention_user_id is required for mention_user component."
+ components.append(Comp.At(qq=mention_user_id))
+ else:
+ return (
+ f"error: unsupported message type '{msg_type}' at index {idx}."
+ )
+ except Exception as exc:
+ return f"error: failed to build messages[{idx}] component: {exc}"
+
+ try:
+ target_session = (
+ MessageSession.from_str(session)
+ if isinstance(session, str)
+ else session
+ )
+ except Exception as exc:
+ return f"error: invalid session: {exc}"
+
+ await context.context.context.send_message(
+ target_session,
+ MessageChain(chain=components),
+ )
+ return f"Message sent to session {target_session}"
+
+
+__all__ = [
+ "SendMessageToUserTool",
+]
diff --git a/astrbot/core/tools/registry.py b/astrbot/core/tools/registry.py
new file mode 100644
index 000000000..eaca4af14
--- /dev/null
+++ b/astrbot/core/tools/registry.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from importlib import import_module
+from typing import TypeVar
+
+from astrbot.core.agent.tool import FunctionTool
+
+TFunctionTool = TypeVar("TFunctionTool", bound=type[FunctionTool])
+
+_BUILTIN_TOOL_MODULES = (
+ "astrbot.core.tools.cron_tools",
+ "astrbot.core.tools.knowledge_base_tools",
+ "astrbot.core.tools.message_tools",
+ "astrbot.core.tools.web_search_tools",
+)
+
+_builtin_tool_classes_by_name: dict[str, type[FunctionTool]] = {}
+_builtin_tool_names_by_class: dict[type[FunctionTool], str] = {}
+_builtin_tools_loaded = False
+
+
+def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str:
+ tool_name = getattr(tool_cls, "name", None)
+ if isinstance(tool_name, str) and tool_name:
+ return tool_name
+
+ dataclass_fields = getattr(tool_cls, "__dataclass_fields__", {})
+ name_field = dataclass_fields.get("name")
+ if name_field is not None and isinstance(name_field.default, str):
+ return name_field.default
+
+ raise ValueError(
+ f"Builtin tool class {tool_cls.__module__}.{tool_cls.__name__} does not define a valid name.",
+ )
+
+
+def builtin_tool(tool_cls: TFunctionTool) -> TFunctionTool:
+ tool_name = _resolve_builtin_tool_name(tool_cls)
+ existing = _builtin_tool_classes_by_name.get(tool_name)
+ if existing is not None and existing is not tool_cls:
+ raise ValueError(
+ f"Builtin tool name conflict detected: {tool_name} is already registered by "
+ f"{existing.__module__}.{existing.__name__}.",
+ )
+
+ _builtin_tool_classes_by_name[tool_name] = tool_cls
+ _builtin_tool_names_by_class[tool_cls] = tool_name
+ return tool_cls
+
+
+def ensure_builtin_tools_loaded() -> None:
+ global _builtin_tools_loaded
+ if _builtin_tools_loaded:
+ return
+
+ for module_name in _BUILTIN_TOOL_MODULES:
+ import_module(module_name)
+
+ _builtin_tools_loaded = True
+
+
+def get_builtin_tool_class(name: str) -> type[FunctionTool] | None:
+ ensure_builtin_tools_loaded()
+ return _builtin_tool_classes_by_name.get(name)
+
+
+def get_builtin_tool_name(tool_cls: type[FunctionTool]) -> str | None:
+ ensure_builtin_tools_loaded()
+ return _builtin_tool_names_by_class.get(tool_cls)
+
+
+def iter_builtin_tool_classes() -> tuple[type[FunctionTool], ...]:
+ ensure_builtin_tools_loaded()
+ return tuple(_builtin_tool_classes_by_name.values())
+
+
+__all__ = [
+ "builtin_tool",
+ "ensure_builtin_tools_loaded",
+ "get_builtin_tool_class",
+ "get_builtin_tool_name",
+ "iter_builtin_tool_classes",
+]
diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py
new file mode 100644
index 000000000..5ca8c3e08
--- /dev/null
+++ b/astrbot/core/tools/web_search_tools.py
@@ -0,0 +1,602 @@
+import asyncio
+import json
+import uuid
+from dataclasses import dataclass as std_dataclass
+from dataclasses import field
+
+import aiohttp
+from pydantic import Field
+from pydantic.dataclasses import dataclass as pydantic_dataclass
+
+from astrbot.core import logger, sp
+from astrbot.core.agent.tool import FunctionTool, ToolExecResult
+from astrbot.core.astr_agent_context import AstrAgentContext
+from astrbot.core.tools.registry import builtin_tool
+
+WEB_SEARCH_TOOL_NAMES = [
+ "web_search_baidu",
+ "web_search_tavily",
+ "tavily_extract_web_page",
+ "web_search_bocha",
+ "web_search_brave",
+]
+
+
+@std_dataclass
+class SearchResult:
+ title: str
+ url: str
+ snippet: str
+ favicon: str | None = None
+
+
+@std_dataclass
+class _KeyRotator:
+ setting_name: str
+ provider_name: str
+ index: int = 0
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+
+ async def get(self, provider_settings: dict) -> str:
+ keys = provider_settings.get(self.setting_name, [])
+ if not keys:
+ raise ValueError(
+ f"Error: {self.provider_name} API key is not configured in AstrBot."
+ )
+
+ async with self.lock:
+ key = keys[self.index]
+ self.index = (self.index + 1) % len(keys)
+ return key
+
+
+_TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
+_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
+_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
+
+
+def normalize_legacy_web_search_config(cfg) -> None:
+ provider_settings = cfg.get("provider_settings")
+ if not provider_settings:
+ return
+
+ changed = False
+ if provider_settings.get(
+ "websearch_provider"
+ ) == "default" and provider_settings.get("web_search", False):
+ provider_settings["web_search"] = False
+ changed = True
+ logger.warning(
+ "The default websearch provider is no longer supported. "
+ "Web search has been disabled and the config was saved.",
+ )
+
+ for setting_name in (
+ "websearch_tavily_key",
+ "websearch_bocha_key",
+ "websearch_brave_key",
+ ):
+ value = provider_settings.get(setting_name)
+ if isinstance(value, str):
+ provider_settings[setting_name] = [value] if value else []
+ changed = True
+
+ if changed:
+ cfg.save_config()
+
+
+def _get_runtime(context) -> tuple[dict, dict, str]:
+ agent_ctx = context.context
+ event = agent_ctx.event
+ cfg = agent_ctx.context.get_config(umo=event.unified_msg_origin)
+ provider_settings = cfg.get("provider_settings", {})
+ return cfg, provider_settings, event.unified_msg_origin
+
+
+def _cache_favicon(url: str, favicon: str | None) -> None:
+ if favicon:
+ sp.temporary_cache["_ws_favicon"][url] = favicon
+
+
+def _search_result_payload(results: list[SearchResult]) -> str:
+ ref_uuid = str(uuid.uuid4())[:4]
+ ret_ls = []
+ for idx, result in enumerate(results, 1):
+ index = f"{ref_uuid}.{idx}"
+ ret_ls.append(
+ {
+ "title": f"{result.title}",
+ "url": f"{result.url}",
+ "snippet": f"{result.snippet}",
+ "index": index,
+ }
+ )
+ _cache_favicon(result.url, result.favicon)
+ return json.dumps({"results": ret_ls}, ensure_ascii=False)
+
+
+async def _tavily_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ tavily_key = await _TAVILY_KEY_ROTATOR.get(provider_settings)
+ header = {
+ "Authorization": f"Bearer {tavily_key}",
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.tavily.com/search",
+ json=payload,
+ headers=header,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Tavily web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ return [
+ SearchResult(
+ title=item.get("title"),
+ url=item.get("url"),
+ snippet=item.get("content"),
+ favicon=item.get("favicon"),
+ )
+ for item in data.get("results", [])
+ ]
+
+
+async def _tavily_extract(provider_settings: dict, payload: dict) -> list[dict]:
+ tavily_key = await _TAVILY_KEY_ROTATOR.get(provider_settings)
+ header = {
+ "Authorization": f"Bearer {tavily_key}",
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.tavily.com/extract",
+ json=payload,
+ headers=header,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Tavily web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ results: list[dict] = data.get("results", [])
+ if not results:
+ raise ValueError(
+ "Error: Tavily web searcher does not return any results."
+ )
+ return results
+
+
+async def _bocha_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ bocha_key = await _BOCHA_KEY_ROTATOR.get(provider_settings)
+ header = {
+ "Authorization": f"Bearer {bocha_key}",
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.bochaai.com/v1/web-search",
+ json=payload,
+ headers=header,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"BoCha web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ rows = data["data"]["webPages"]["value"]
+ return [
+ SearchResult(
+ title=item.get("name"),
+ url=item.get("url"),
+ snippet=item.get("snippet"),
+ favicon=item.get("siteIcon"),
+ )
+ for item in rows
+ ]
+
+
+async def _brave_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ brave_key = await _BRAVE_KEY_ROTATOR.get(provider_settings)
+ header = {
+ "Accept": "application/json",
+ "X-Subscription-Token": brave_key,
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.get(
+ "https://api.search.brave.com/res/v1/web/search",
+ params=payload,
+ headers=header,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Brave web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ rows = data.get("web", {}).get("results", [])
+ return [
+ SearchResult(
+ title=item.get("title", ""),
+ url=item.get("url", ""),
+ snippet=item.get("description", ""),
+ )
+ for item in rows
+ ]
+
+
+async def _baidu_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ api_key = provider_settings.get("websearch_baidu_app_builder_key", "")
+ if not api_key:
+ raise ValueError("Error: Baidu AI Search API key is not configured in AstrBot.")
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "X-Appbuilder-Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://qianfan.baidubce.com/v2/ai_search/web_search",
+ json=payload,
+ headers=headers,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Baidu AI Search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ references = data.get("references", [])
+ return [
+ SearchResult(
+ title=item.get("title", ""),
+ url=item.get("url", ""),
+ snippet=item.get("content", ""),
+ favicon=item.get("icon"),
+ )
+ for item in references
+ if item.get("url")
+ ]
+
+
+@builtin_tool
+@pydantic_dataclass
+class TavilyWebSearchTool(FunctionTool[AstrAgentContext]):
+ name: str = "web_search_tavily"
+ description: str = (
+ "A web search tool that uses Tavily to search the web for relevant content. "
+ "Ideal for gathering current information, news, and detailed web content analysis."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Required. Search query."},
+ "max_results": {
+ "type": "integer",
+ "description": "Optional. The maximum number of results to return. Default is 7. Range is 5-20.",
+ },
+ "search_depth": {
+ "type": "string",
+ "description": 'Optional. The depth of the search, must be one of "basic", "advanced". Default is "basic".',
+ },
+ "topic": {
+ "type": "string",
+ "description": 'Optional. The topic of the search, must be one of "general", "news". Default is "general".',
+ },
+ "days": {
+ "type": "integer",
+ "description": 'Optional. The number of days back from the current date to include in the search results. This only applies when topic is "news".',
+ },
+ "time_range": {
+ "type": "string",
+ "description": 'Optional. The time range back from the current date to include in the search results. Must be one of "day", "week", "month", "year".',
+ },
+ "start_date": {
+ "type": "string",
+ "description": "Optional. The start date for the search results in the format YYYY-MM-DD.",
+ },
+ "end_date": {
+ "type": "string",
+ "description": "Optional. The end date for the search results in the format YYYY-MM-DD.",
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_tavily_key", []):
+ return "Error: Tavily API key is not configured in AstrBot."
+
+ search_depth = kwargs.get("search_depth", "basic")
+ if search_depth not in ["basic", "advanced"]:
+ search_depth = "basic"
+
+ topic = kwargs.get("topic", "general")
+ if topic not in ["general", "news"]:
+ topic = "general"
+
+ payload = {
+ "query": kwargs["query"],
+ "max_results": kwargs.get("max_results", 7),
+ "include_favicon": True,
+ "search_depth": search_depth,
+ "topic": topic,
+ }
+ if topic == "news":
+ payload["days"] = kwargs.get("days", 3)
+
+ time_range = kwargs.get("time_range", "")
+ if time_range in ["day", "week", "month", "year"]:
+ payload["time_range"] = time_range
+ if kwargs.get("start_date"):
+ payload["start_date"] = kwargs["start_date"]
+ if kwargs.get("end_date"):
+ payload["end_date"] = kwargs["end_date"]
+
+ results = await _tavily_search(provider_settings, payload)
+ if not results:
+ return "Error: Tavily web searcher does not return any results."
+ return _search_result_payload(results)
+
+
+@builtin_tool
+@pydantic_dataclass
+class TavilyExtractWebPageTool(FunctionTool[AstrAgentContext]):
+ name: str = "tavily_extract_web_page"
+ description: str = "Extract the content of a web page using Tavily."
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Required. A URL to extract content from.",
+ },
+ "extract_depth": {
+ "type": "string",
+ "description": 'Optional. The depth of the extraction, must be one of "basic", "advanced". Default is "basic".',
+ },
+ },
+ "required": ["url"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_tavily_key", []):
+ return "Error: Tavily API key is not configured in AstrBot."
+
+ url = str(kwargs.get("url", "")).strip()
+ if not url:
+ return "Error: url must be a non-empty string."
+
+ extract_depth = kwargs.get("extract_depth", "basic")
+ if extract_depth not in ["basic", "advanced"]:
+ extract_depth = "basic"
+
+ results = await _tavily_extract(
+ provider_settings,
+ {"urls": [url], "extract_depth": extract_depth},
+ )
+ ret_ls = []
+ for result in results:
+ ret_ls.append(f"URL: {result.get('url', 'No URL')}")
+ ret_ls.append(f"Content: {result.get('raw_content', 'No content')}")
+ ret = "\n".join(ret_ls)
+ return ret or "Error: Tavily web searcher does not return any results."
+
+
+@builtin_tool
+@pydantic_dataclass
+class BochaWebSearchTool(FunctionTool[AstrAgentContext]):
+ name: str = "web_search_bocha"
+ description: str = (
+ "A web search tool based on Bocha Search API, used to retrieve web pages "
+ "related to the user's query."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Required. User's search query.",
+ },
+ "freshness": {
+ "type": "string",
+ "description": 'Optional. Time range of the search. Recommended value is "noLimit".',
+ },
+ "summary": {
+ "type": "boolean",
+ "description": "Optional. Whether to include a text summary for each search result.",
+ },
+ "include": {
+ "type": "string",
+ "description": "Optional. Domains to include in the search, separated by | or ,.",
+ },
+ "exclude": {
+ "type": "string",
+ "description": "Optional. Domains to exclude from the search, separated by | or ,.",
+ },
+ "count": {
+ "type": "integer",
+ "description": "Optional. Number of search results to return. Range: 1-50.",
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_bocha_key", []):
+ return "Error: BoCha API key is not configured in AstrBot."
+
+ payload = {
+ "query": kwargs["query"],
+ "count": kwargs.get("count", 10),
+ "summary": bool(kwargs.get("summary", False)),
+ }
+ if kwargs.get("freshness"):
+ payload["freshness"] = kwargs["freshness"]
+ if kwargs.get("include"):
+ payload["include"] = kwargs["include"]
+ if kwargs.get("exclude"):
+ payload["exclude"] = kwargs["exclude"]
+
+ results = await _bocha_search(provider_settings, payload)
+ if not results:
+ return "Error: BoCha web searcher does not return any results."
+ return _search_result_payload(results)
+
+
+@builtin_tool
+@pydantic_dataclass
+class BraveWebSearchTool(FunctionTool[AstrAgentContext]):
+ name: str = "web_search_brave"
+ description: str = "A web search tool based on Brave Search API."
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Required. Search query."},
+ "count": {
+ "type": "integer",
+ "description": "Optional. Number of results to return. Range: 1-20.",
+ },
+ "country": {
+ "type": "string",
+ "description": 'Optional. Country code for region-specific results, for example "US" or "CN".',
+ },
+ "search_lang": {
+ "type": "string",
+ "description": 'Optional. Brave language code, for example "zh-hans" or "en".',
+ },
+ "freshness": {
+ "type": "string",
+ "description": 'Optional. One of "day", "week", "month", "year".',
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_brave_key", []):
+ return "Error: Brave API key is not configured in AstrBot."
+
+ count = int(kwargs.get("count", 10))
+ if count < 1:
+ count = 1
+ if count > 20:
+ count = 20
+
+ payload = {
+ "q": kwargs["query"],
+ "count": count,
+ "country": kwargs.get("country", "US"),
+ "search_lang": kwargs.get("search_lang", "zh-hans"),
+ }
+ freshness = kwargs.get("freshness", "")
+ if freshness in ["day", "week", "month", "year"]:
+ payload["freshness"] = freshness
+
+ results = await _brave_search(provider_settings, payload)
+ if not results:
+ return "Error: Brave web searcher does not return any results."
+ return _search_result_payload(results)
+
+
+@builtin_tool
+@pydantic_dataclass
+class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
+ name: str = "web_search_baidu"
+ description: str = (
+ "A web search tool based on Baidu AI Search. "
+ "Use this for real-time web retrieval when Baidu AI Search is configured."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Required. Search query."},
+ "top_k": {
+ "type": "integer",
+ "description": "Optional. Number of web results to return. Maximum 50. Default is 10.",
+ },
+ "search_recency_filter": {
+ "type": "string",
+ "description": 'Optional. One of "week", "month", "semiyear", "year".',
+ },
+ "site": {
+ "type": "string",
+ "description": "Optional. Restrict search to specific sites, separated by commas.",
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_baidu_app_builder_key", ""):
+ return "Error: Baidu AI Search API key is not configured in AstrBot."
+
+ top_k = int(kwargs.get("top_k", 10))
+ if top_k < 1:
+ top_k = 1
+ if top_k > 50:
+ top_k = 50
+
+ payload = {
+ "messages": [{"role": "user", "content": str(kwargs["query"])[:72]}],
+ "search_source": "baidu_search_v2",
+ "resource_type_filter": [{"type": "web", "top_k": top_k}],
+ }
+
+ search_recency_filter = kwargs.get("search_recency_filter", "")
+ if search_recency_filter in ["week", "month", "semiyear", "year"]:
+ payload["search_recency_filter"] = search_recency_filter
+
+ site = str(kwargs.get("site", "")).strip()
+ if site:
+ sites = [s.strip() for s in site.replace("|", ",").split(",") if s.strip()]
+ if sites:
+ payload["search_filter"] = {"match": {"site": sites[:100]}}
+
+ results = await _baidu_search(provider_settings, payload)
+ if not results:
+ return "Error: Baidu AI Search does not return any results."
+ return _search_result_payload(results)
+
+
+__all__ = [
+ "BaiduWebSearchTool",
+ "BochaWebSearchTool",
+ "BraveWebSearchTool",
+ "TavilyExtractWebPageTool",
+ "TavilyWebSearchTool",
+ "WEB_SEARCH_TOOL_NAMES",
+ "normalize_legacy_web_search_config",
+]
diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py
index d8ef3623b..f1c59e954 100644
--- a/astrbot/dashboard/utils.py
+++ b/astrbot/dashboard/utils.py
@@ -1,11 +1,15 @@
import base64
import traceback
from io import BytesIO
+from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
+if TYPE_CHECKING:
+ from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
+
async def generate_tsne_visualization(
query: str, kb_names: list[str], kb_manager: KnowledgeBaseManager
diff --git a/dashboard/src/assets/images/platform_logos/mattermost.svg b/dashboard/src/assets/images/platform_logos/mattermost.svg
new file mode 100644
index 000000000..9b055325a
--- /dev/null
+++ b/dashboard/src/assets/images/platform_logos/mattermost.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue
index 20a36ae65..a3beefaa2 100644
--- a/dashboard/src/components/extension/McpServersSection.vue
+++ b/dashboard/src/components/extension/McpServersSection.vue
@@ -438,8 +438,9 @@ export default {
mounted() {
this.getServers();
this.refreshInterval = setInterval(() => {
+ // 轮询时间延长到30秒,减少服务器压力,同时保持数据相对新鲜
this.getServers();
- }, 5000);
+ }, 30000);
},
unmounted() {
if (this.refreshInterval) {
diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts
index ebce51da6..beb33c912 100644
--- a/dashboard/src/components/extension/componentPanel/types.ts
+++ b/dashboard/src/components/extension/componentPanel/types.ts
@@ -100,6 +100,7 @@ export interface ToolItem {
name: string;
description: string;
active: boolean;
+ readonly?: boolean;
parameters?: {
properties?: Record;
};
diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue
index 20ea11ea5..5cb9f5ccc 100644
--- a/dashboard/src/components/shared/ReadmeDialog.vue
+++ b/dashboard/src/components/shared/ReadmeDialog.vue
@@ -4,7 +4,7 @@ import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import axios from "@/utils/request";
import DOMPurify from "dompurify";
-import "highlight.js/styles/github-dark.css";
+import "highlight.js/styles/github.css";
import { useI18n } from "@/i18n/composables";
// 1. 在 setup 作用域创建 MarkdownIt 实例
diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
index 193353c1a..c277f607b 100644
--- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
@@ -121,6 +121,10 @@
"description": "BoCha API Key",
"hint": "Multiple keys can be added for rotation."
},
+ "websearch_brave_key": {
+ "description": "Brave Search API Key",
+ "hint": "Multiple keys can be added for rotation."
+ },
"websearch_baidu_app_builder_key": {
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
"hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
@@ -568,6 +572,18 @@
"description": "Bot Token",
"hint": "If you are in mainland China, set a proxy or change api_base in Other Settings."
},
+ "mattermost_url": {
+ "description": "Mattermost URL",
+ "hint": "Mattermost service URL, for example https://chat.example.com."
+ },
+ "mattermost_bot_token": {
+ "description": "Mattermost Bot Token",
+ "hint": "The access token generated after creating a bot account in Mattermost."
+ },
+ "mattermost_reconnect_delay": {
+ "description": "Mattermost Reconnect Delay",
+ "hint": "Delay in seconds before reconnecting after the WebSocket disconnects. Defaults to 5 seconds."
+ },
"type": {
"description": "Adapter Type"
},
@@ -1514,6 +1530,10 @@
"description": "Notes for local Whisper deployment",
"hint": "Before enabling, install the openai-whisper library (NVIDIA users download ~2GB mainly for torch and cuda; CPU users download ~1GB), and install ffmpeg. Otherwise STT will not work."
},
+ "whisper_device": {
+ "description": "Inference device",
+ "hint": "Whisper inference device. Apple Silicon can use mps; other environments should use cpu. If mps is selected but unavailable, AstrBot will fall back to cpu."
+ },
"id": {
"description": "ID"
},
diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json
index 2c68b8243..e0d762010 100644
--- a/dashboard/src/i18n/locales/en-US/features/tool-use.json
+++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json
@@ -45,6 +45,7 @@
"required": "Required",
"origin": "Origin",
"originName": "Origin Name",
+ "readonly": "Read-only",
"actions": "Actions"
}
},
@@ -153,6 +154,7 @@
"configParseError": "Configuration parse error: {error}",
"noAvailableConfig": "No available configuration",
"toggleToolSuccess": "Tool status toggled successfully!",
+ "toggleToolReadonly": "Builtin tools are read-only and cannot be enabled or disabled.",
"toggleToolError": "Failed to toggle tool status: {error}",
"testError": "Test connection failed: {error}"
}
diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
index 911b4b221..0776e925d 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
@@ -121,6 +121,10 @@
"description": "API-ключ BoCha",
"hint": "Можно добавить несколько ключей для ротации."
},
+ "websearch_brave_key": {
+ "description": "API-ключ Brave Search",
+ "hint": "Можно добавить несколько ключей для ротации."
+ },
"websearch_baidu_app_builder_key": {
"description": "API-ключ Baidu Qianfan APP Builder",
"hint": "Ссылка: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
@@ -564,6 +568,18 @@
"description": "Токен бота",
"hint": "Если вы находитесь в материковом Китае, установите прокси или измените api_base в разделе «Другие настройки»."
},
+ "mattermost_url": {
+ "description": "URL Mattermost",
+ "hint": "Адрес сервиса Mattermost, например https://chat.example.com."
+ },
+ "mattermost_bot_token": {
+ "description": "Bot Token Mattermost",
+ "hint": "Токен доступа, созданный после добавления bot-аккаунта в Mattermost."
+ },
+ "mattermost_reconnect_delay": {
+ "description": "Задержка переподключения Mattermost",
+ "hint": "Сколько секунд ждать перед переподключением после разрыва WebSocket. По умолчанию 5 секунд."
+ },
"type": {
"description": "Тип адаптера"
},
@@ -1511,6 +1527,10 @@
"description": "Заметки по локальному развертыванию Whisper",
"hint": "Перед включением установите openai-whisper и ffmpeg."
},
+ "whisper_device": {
+ "description": "Устройство инференса",
+ "hint": "Устройство для инференса Whisper. На Apple Silicon можно выбрать mps; в остальных средах рекомендуется cpu. Если выбран mps, но он недоступен, AstrBot автоматически переключится на cpu."
+ },
"id": {
"description": "ID провайдера"
},
diff --git a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
index df4cd0dbf..4efb31e1e 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
@@ -45,6 +45,7 @@
"required": "Обяз.",
"origin": "Источник",
"originName": "Имя источника",
+ "readonly": "Только чтение",
"actions": "Действия"
}
},
@@ -153,6 +154,7 @@
"configParseError": "Ошибка разбора конфигурации: {error}",
"noAvailableConfig": "Конфигурация отсутствует",
"toggleToolSuccess": "Статус инструмента изменен!",
+ "toggleToolReadonly": "Встроенные инструменты доступны только для чтения и не могут быть включены или выключены.",
"toggleToolError": "Не удалось изменить статус: {error}",
"testError": "Ошибка теста связи: {error}"
},
@@ -192,4 +194,4 @@
"tokenHelp": "Как получить токен доступа ModelScope? Нажмите кнопку справа для получения инструкций"
}
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
index a9dd99e99..8d1c7a4e9 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
@@ -123,6 +123,10 @@
"description": "BoCha API Key",
"hint": "可添加多个 Key 进行轮询。"
},
+ "websearch_brave_key": {
+ "description": "Brave Search API Key",
+ "hint": "可添加多个 Key 进行轮询。"
+ },
"websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
@@ -566,6 +570,18 @@
"description": "Bot Token",
"hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。"
},
+ "mattermost_url": {
+ "description": "Mattermost URL",
+ "hint": "Mattermost 服务地址,例如 https://chat.example.com。"
+ },
+ "mattermost_bot_token": {
+ "description": "Mattermost Bot Token",
+ "hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。"
+ },
+ "mattermost_reconnect_delay": {
+ "description": "Mattermost 重连延迟",
+ "hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。"
+ },
"type": {
"description": "适配器类型"
},
@@ -1512,6 +1528,10 @@
"description": "本地部署 Whisper 模型须知",
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。"
},
+ "whisper_device": {
+ "description": "推理设备",
+ "hint": "Whisper 推理设备。Apple Silicon 可选 mps;其他环境建议使用 cpu。若指定 mps 但当前环境不可用,将自动回退到 cpu。"
+ },
"id": {
"description": "ID"
},
diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
index f6e6c4407..66244947b 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
@@ -45,6 +45,7 @@
"required": "必填",
"origin": "来源",
"originName": "来源名称",
+ "readonly": "只读",
"actions": "操作"
}
},
@@ -153,7 +154,8 @@
"configParseError": "配置解析错误: {error}",
"noAvailableConfig": "无可用配置",
"toggleToolSuccess": "工具状态切换成功!",
+ "toggleToolReadonly": "内置工具为只读,无法进行启用或停用操作。",
"toggleToolError": "工具状态切换失败: {error}",
"testError": "测试连接失败: {error}"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/scss/components/_HljsDark.scss b/dashboard/src/scss/components/_HljsDark.scss
new file mode 100644
index 000000000..1b084ad58
--- /dev/null
+++ b/dashboard/src/scss/components/_HljsDark.scss
@@ -0,0 +1,107 @@
+// highlight.js dark mode overrides
+// Scoped to the dark Vuetify theme so the default github.css light styles work in light mode.
+.v-theme--PurpleThemeDark {
+ .hljs {
+ background: transparent;
+ color: #adbac7;
+ }
+
+ .hljs-doctag,
+ .hljs-keyword,
+ .hljs-meta .hljs-keyword,
+ .hljs-template-tag,
+ .hljs-template-variable,
+ .hljs-type,
+ .hljs-variable.language_ {
+ color: #f47067;
+ }
+
+ .hljs-title,
+ .hljs-title.class_,
+ .hljs-title.class_.inherited__,
+ .hljs-title.function_ {
+ color: #dcbdfb;
+ }
+
+ .hljs-attr,
+ .hljs-attribute,
+ .hljs-literal,
+ .hljs-meta,
+ .hljs-number,
+ .hljs-operator,
+ .hljs-selector-attr,
+ .hljs-selector-class,
+ .hljs-selector-id,
+ .hljs-variable {
+ color: #6cb6ff;
+ }
+
+ .hljs-meta .hljs-string,
+ .hljs-regexp,
+ .hljs-string {
+ color: #96d0ff;
+ }
+
+ .hljs-built_in,
+ .hljs-symbol {
+ color: #f69d50;
+ }
+
+ .hljs-code,
+ .hljs-comment,
+ .hljs-formula {
+ color: #768390;
+ }
+
+ .hljs-name,
+ .hljs-quote,
+ .hljs-selector-pseudo,
+ .hljs-selector-tag {
+ color: #8ddb8c;
+ }
+
+ .hljs-subst {
+ color: #adbac7;
+ }
+
+ .hljs-section {
+ color: #316dca;
+ font-weight: bold;
+ }
+
+ .hljs-bullet {
+ color: #eac55f;
+ }
+
+ .hljs-emphasis {
+ color: #adbac7;
+ font-style: italic;
+ }
+
+ .hljs-strong {
+ color: #adbac7;
+ font-weight: bold;
+ }
+
+ .hljs-addition {
+ color: #b4f1b4;
+ background-color: #1b4721;
+ }
+
+ .hljs-deletion {
+ color: #ffd8d3;
+ background-color: #78191b;
+ }
+
+ // markstream-vue dark mode variables override
+ // markstream-vue expects a `.dark` ancestor to activate its dark palette,
+ // but Vuetify uses `.v-theme--PurpleThemeDark` instead.
+ .markstream-vue {
+ --border: 217.2 32.6% 17.5%;
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --secondary: 217.2 32.6% 17.5%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ }
+}
diff --git a/dashboard/src/views/alkaid/LongTermMemory.vue b/dashboard/src/views/alkaid/LongTermMemory.vue
index 5c629e84d..e020b265a 100644
--- a/dashboard/src/views/alkaid/LongTermMemory.vue
+++ b/dashboard/src/views/alkaid/LongTermMemory.vue
@@ -1284,4 +1284,19 @@ export default {
overflow: auto;
font-size: 12px;
}
+
+
+
+
diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs
index a4f104de0..5f6e1d242 100644
--- a/docs/.vitepress/config.mjs
+++ b/docs/.vitepress/config.mjs
@@ -98,6 +98,7 @@ export default defineConfig({
{ text: "Telegram", link: "/telegram" },
{ text: "LINE", link: "/line" },
{ text: "Slack", link: "/slack" },
+ { text: "Mattermost", link: "/mattermost" },
{ text: "Misskey", link: "/misskey" },
{ text: "Discord", link: "/discord" },
{ text: "KOOK", link: "/kook" },
@@ -336,6 +337,7 @@ export default defineConfig({
{ text: "Telegram", link: "/telegram" },
{ text: "LINE", link: "/line" },
{ text: "Slack", link: "/slack" },
+ { text: "Mattermost", link: "/mattermost" },
{ text: "Misskey", link: "/misskey" },
{ text: "Discord", link: "/discord" },
{
diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md
index a33f14105..e27111e76 100644
--- a/docs/en/dev/astrbot-config.md
+++ b/docs/en/dev/astrbot-config.md
@@ -58,8 +58,10 @@ The default AstrBot configuration is as follows:
"provider_pool": ["*"], # "*" means use all available providers
"wake_prefix": "",
"web_search": False,
- "websearch_provider": "default",
+ "websearch_provider": "tavily",
"websearch_tavily_key": [],
+ "websearch_bocha_key": [],
+ "websearch_brave_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
@@ -286,16 +288,25 @@ Whether to enable AstrBot's built-in web search capability. Default is `false`.
#### `provider_settings.websearch_provider`
-Web search provider type. Default is `default`. Currently supports `default` and `tavily`.
-
-- `default`: Works best when Google is accessible. If Google fails, it tries Bing and Sogou in order.
+Web search provider type. Default is `tavily`. Currently supports `tavily`, `bocha`, `baidu_ai_search`, and `brave`.
- `tavily`: Uses the Tavily search engine.
+- `bocha`: Uses the BoCha search engine.
+- `baidu_ai_search`: Uses Baidu AI Search (MCP).
+- `brave`: Uses Brave Search API.
#### `provider_settings.websearch_tavily_key`
API Key list for the Tavily search engine. Required when using `tavily` as the web search provider.
+#### `provider_settings.websearch_bocha_key`
+
+API Key list for the BoCha search engine. Required when using `bocha` as the web search provider.
+
+#### `provider_settings.websearch_brave_key`
+
+API Key list for the Brave search engine. Required when using `brave` as the web search provider.
+
#### `provider_settings.web_search_link`
Whether to prompt the model to include links to search results in the reply. Default is `false`.
diff --git a/docs/en/platform/mattermost.md b/docs/en/platform/mattermost.md
new file mode 100644
index 000000000..feb071684
--- /dev/null
+++ b/docs/en/platform/mattermost.md
@@ -0,0 +1,139 @@
+# Connecting to Mattermost
+
+The Mattermost adapter connects to your Mattermost server through a Bot Token and WebSocket. After finishing the two parts below, AstrBot can send and receive messages in Mattermost channels and direct messages.
+
+## Create the AstrBot Mattermost Platform Adapter
+
+Go to the `Bots` page, click `+ Create Bot`, and choose `Mattermost`.
+
+On the configuration page, enable it first, then fill in:
+
+- `Mattermost URL`: your Mattermost server URL, for example `https://chat.example.com`
+- `Mattermost Bot Token`: the access token generated after creating a bot account in Mattermost
+- `Mattermost Reconnect Delay`: how long AstrBot waits before reconnecting after a WebSocket disconnect, default `5`
+
+Then click save.
+
+## Deploy Mattermost
+
+If you do not have a Mattermost server yet, use the official Mattermost Docker Compose repository:
+
+- Official docs: https://docs.mattermost.com/deployment-guide/server/containers/install-docker.html
+- Official repository: https://github.com/mattermost/docker
+
+The current quick-start flow recommended by Mattermost is:
+
+```bash
+git clone https://github.com/mattermost/docker
+cd docker
+cp env.example .env
+```
+
+Then update at least these values in `.env`:
+
+- `DOMAIN`
+- `MATTERMOST_IMAGE_TAG`
+- It is also recommended to set `MM_SUPPORTSETTINGS_SUPPORTEMAIL`
+
+Create the data directories and set ownership:
+
+```bash
+mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes}
+sudo chown -R 2000:2000 ./volumes/app/mattermost
+```
+
+Choose one startup mode:
+
+Without the bundled NGINX:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.without-nginx.yml up -d
+```
+
+With the bundled NGINX:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
+```
+
+Access URLs:
+
+- Without NGINX: `http://your-domain:8065`
+- With NGINX: `https://your-domain`
+
+> [!TIP]
+> Mattermost currently states that production Docker support is Linux-only. macOS and Windows are better suited for development or testing.
+
+## Create a Bot in Mattermost
+
+### 1. Enable Bot Account Creation
+
+Open the Mattermost system console:
+
+`System Console > Integrations > Bot Accounts`
+
+Enable `Enable Bot Account Creation`.
+
+### 2. Create the Bot Account
+
+Go to:
+
+`Product menu > Integrations > Bot Accounts`
+
+Click `Add Bot Account` and fill in:
+
+- `Username`
+- `Display Name`
+- `Description`
+
+After creation, copy the generated Bot Token. It is shown only once. Paste it into AstrBot's `Mattermost Bot Token` field.
+
+### 3. Add the Bot to a Channel
+
+Add the bot to the channel where AstrBot should work. Otherwise the bot will not be able to properly receive and send messages in that channel.
+
+## How to Fill in Mattermost URL
+
+`Mattermost URL` should be the external URL of your Mattermost server, without a trailing slash. For example:
+
+```text
+https://chat.example.com
+```
+
+If you are only testing locally, you can also use:
+
+```text
+http://127.0.0.1:8065
+```
+
+If both AstrBot and Mattermost run in containers, prefer an address reachable from the AstrBot container, such as the Mattermost service name on the same Docker network.
+
+## Start and Verify
+
+After saving the AstrBot platform adapter configuration:
+
+1. Make sure the AstrBot logs do not show Mattermost authentication or WebSocket connection errors.
+2. Send a message in a channel that includes the bot, or send the bot a direct message.
+3. If AstrBot replies normally, the integration is working.
+
+## Common Issues
+
+### Invalid Token Errors
+
+Usually one of these:
+
+- You copied a user token instead of the bot token
+- The token contains extra spaces
+- The bot account was deleted or the token was regenerated
+
+### Connected but No Channel Messages Arrive
+
+Check these first:
+
+- The bot has been added to the target channel
+- `Mattermost URL` points to an address AstrBot can actually reach
+- Your Mattermost reverse proxy forwards WebSocket traffic correctly
+
+### Mattermost Opens in Browser but AstrBot Still Cannot Connect
+
+If AstrBot runs in a container while `Mattermost URL` is set to `localhost` or `127.0.0.1`, AstrBot will connect to itself instead of the Mattermost service. In that case, switch to an address reachable inside the Docker network.
diff --git a/docs/en/use/websearch.md b/docs/en/use/websearch.md
index 82e77bb93..119166d38 100644
--- a/docs/en/use/websearch.md
+++ b/docs/en/use/websearch.md
@@ -1,7 +1,7 @@
# Web Search
-The web search feature aims to provide large language models with the ability to invoke search engines like Google, Bing, and Sogou to obtain recent world information, which can improve the accuracy of model responses and reduce hallucinations to some extent.
+The web search feature gives large language models internet retrieval capability for recent information, which can improve response accuracy and reduce hallucinations to some extent.
AstrBot's built-in web search functionality relies on the large language model's `function calling` capability. If you're not familiar with function calling, please refer to: [Function Calling](/use/websearch).
@@ -14,22 +14,28 @@ When using a large language model that supports function calling with the web se
And other prompts with search intent to trigger the model to invoke the search tool.
-AstrBot supports 3 types of web search source integration: `default`, `Tavily`, and `Baidu AI Search`.
-
-The former uses AstrBot's built-in web search requester to query Google, Bing, and Sogou search engines, performing best in network environments with Google access. **We recommend using Tavily**.
+AstrBot currently supports 4 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, and `Brave`.

-Go to `Configuration`, scroll down to find Web Search, where you can select `default` (default, not recommended) or `Tavily`.
-
-### default (Not Recommended)
-
-If your device is in China and you have a proxy, you can enable the proxy and enter the HTTP proxy address in `Admin Panel - Other Configuration - HTTP Proxy` to apply the proxy.
+Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, or `Brave`.
### Tavily
Go to [Tavily](https://app.tavily.com/home) to get an API Key, then fill it in the corresponding configuration item.
+### BoCha
+
+Get an API Key from the BoCha platform, then fill it in the corresponding configuration item.
+
+### Baidu AI Search
+
+Get an API Key from Baidu Qianfan APP Builder, then fill it in the corresponding configuration item.
+
+### Brave
+
+Get an API Key from Brave Search, then fill it in the corresponding configuration item.
+
If you use Tavily as your web search source, you will get a better experience optimization on AstrBot ChatUI, including citation source display and more:

diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md
index cd5f16094..9b13cf84d 100644
--- a/docs/zh/dev/astrbot-config.md
+++ b/docs/zh/dev/astrbot-config.md
@@ -58,8 +58,10 @@ AstrBot 默认配置如下:
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
"wake_prefix": "",
"web_search": False,
- "websearch_provider": "default",
+ "websearch_provider": "tavily",
"websearch_tavily_key": [],
+ "websearch_bocha_key": [],
+ "websearch_brave_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
@@ -286,16 +288,25 @@ ID 白名单。填写后,将只处理所填写的 ID 发来的消息事件。
#### `provider_settings.websearch_provider`
-网页搜索提供商类型。默认为 `default`。目前支持 `default` 和 `tavily`。
-
-- `default`:能访问 Google 时效果最佳。如果 Google 访问失败,程序会依次访问 Bing, Sogo 搜索引擎。
+网页搜索提供商类型。默认为 `tavily`。目前支持 `tavily`、`bocha`、`baidu_ai_search`、`brave`。
- `tavily`:使用 Tavily 搜索引擎。
+- `bocha`:使用 BoCha 搜索引擎。
+- `baidu_ai_search`:使用百度 AI Search(MCP)。
+- `brave`:使用 Brave Search API。
#### `provider_settings.websearch_tavily_key`
Tavily 搜索引擎的 API Key 列表。使用 `tavily` 作为网页搜索提供商时需要填写。
+#### `provider_settings.websearch_bocha_key`
+
+BoCha 搜索引擎的 API Key 列表。使用 `bocha` 作为网页搜索提供商时需要填写。
+
+#### `provider_settings.websearch_brave_key`
+
+Brave 搜索引擎的 API Key 列表。使用 `brave` 作为网页搜索提供商时需要填写。
+
#### `provider_settings.web_search_link`
是否在回复中提示模型附上搜索结果的链接。默认为 `false`。
diff --git a/docs/zh/platform/mattermost.md b/docs/zh/platform/mattermost.md
new file mode 100644
index 000000000..be67494e0
--- /dev/null
+++ b/docs/zh/platform/mattermost.md
@@ -0,0 +1,139 @@
+# 接入 Mattermost
+
+Mattermost 适配器通过 Bot Token 和 WebSocket 连接到 Mattermost 服务器。完成下面两部分配置后,AstrBot 就可以在 Mattermost 频道和私聊中收发消息。
+
+## 创建 AstrBot Mattermost 平台适配器
+
+进入 `机器人` 页面,点击 `+ 创建机器人`,选择 `Mattermost`。
+
+在配置页中先打开 `启用`,然后填写以下字段:
+
+- `Mattermost URL`:你的 Mattermost 服务地址,例如 `https://chat.example.com`
+- `Mattermost Bot Token`:在 Mattermost 中创建 Bot 账户后生成的访问令牌
+- `Mattermost 重连延迟`:WebSocket 断开后的重连等待时间,默认 `5`
+
+填写完成后点击保存。
+
+## 部署 Mattermost
+
+如果你还没有 Mattermost 服务,建议直接使用 Mattermost 官方提供的 Docker Compose 仓库:
+
+- 官方文档:https://docs.mattermost.com/deployment-guide/server/containers/install-docker.html
+- 官方仓库:https://github.com/mattermost/docker
+
+官方当前推荐的快速部署步骤如下:
+
+```bash
+git clone https://github.com/mattermost/docker
+cd docker
+cp env.example .env
+```
+
+然后至少修改 `.env` 中的:
+
+- `DOMAIN`
+- `MATTERMOST_IMAGE_TAG`
+- 建议补充 `MM_SUPPORTSETTINGS_SUPPORTEMAIL`
+
+接着创建数据目录并设置权限:
+
+```bash
+mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes}
+sudo chown -R 2000:2000 ./volumes/app/mattermost
+```
+
+启动方式二选一:
+
+不使用内置 NGINX:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.without-nginx.yml up -d
+```
+
+使用内置 NGINX:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
+```
+
+访问地址:
+
+- 不使用 NGINX:`http://你的域名:8065`
+- 使用 NGINX:`https://你的域名`
+
+> [!TIP]
+> Mattermost 官方当前说明中,Docker 生产支持仅限 Linux。macOS 和 Windows 更适合开发或测试用途。
+
+## 在 Mattermost 中创建 Bot
+
+### 1. 开启 Bot 账户创建
+
+进入 Mattermost 的系统控制台:
+
+`System Console > Integrations > Bot Accounts`
+
+开启 `Enable Bot Account Creation`。
+
+### 2. 创建 Bot 账户
+
+进入:
+
+`Product menu(左上角的图标) > Integrations > Bot Accounts`
+
+点击 `Add Bot Account`,填写:
+
+- `Username`
+- `Display Name`
+- `Description`
+
+创建完成后复制生成的 Bot Token。这个 Token 只会展示一次,随后填写到 AstrBot 的 `Mattermost Bot Token` 中。
+
+### 3. 将 Bot 加入频道
+
+把刚创建的 Bot 添加到你准备让 AstrBot 工作的频道中,否则机器人无法在该频道正常收发消息。
+
+## Mattermost URL 如何填写
+
+`Mattermost URL` 填 Mattermost 的外部访问地址,不要带结尾斜杠。例如:
+
+```text
+https://chat.example.com
+```
+
+如果你当前只是在本机测试,也可以填写:
+
+```text
+http://127.0.0.1:8065
+```
+
+如果 AstrBot 和 Mattermost 都在 Docker 中运行,请优先填写 AstrBot 容器可访问到的地址,例如同一 Docker 网络中的服务名地址。
+
+## 启动并验证
+
+保存 AstrBot 平台适配器配置后:
+
+1. 确保 AstrBot 日志中没有出现 Mattermost 认证失败或 WebSocket 连接失败。
+2. 在 Mattermost 中向 Bot 所在频道发送消息,或直接给 Bot 发私聊。
+3. 如果 AstrBot 正常回复,说明接入成功。
+
+## 常见问题
+
+### 提示 Token 无效
+
+通常是以下原因:
+
+- 复制的不是 Bot Token
+- Token 复制时带了空格
+- Bot 账户被删除或重新生成了 Token
+
+### 连接成功但收不到频道消息
+
+优先检查:
+
+- Bot 是否已经加入目标频道
+- Mattermost URL 是否填写为 AstrBot 实际可访问的地址
+- Mattermost 反向代理是否正确转发了 WebSocket 请求
+
+### 本机部署能打开页面,但 AstrBot 连接不到
+
+如果 AstrBot 运行在容器里,而 Mattermost URL 填的是 `localhost` 或 `127.0.0.1`,那么 AstrBot 实际连接到的是它自己的容器,而不是 Mattermost。此时应改为 Docker 网络内可访问的地址。
diff --git a/docs/zh/use/websearch.md b/docs/zh/use/websearch.md
index 93200c44b..9173d40ad 100644
--- a/docs/zh/use/websearch.md
+++ b/docs/zh/use/websearch.md
@@ -1,6 +1,6 @@
# 网页搜索
-网页搜索功能旨在提供大模型调用 Google,Bing,搜狗等搜索引擎以获取世界最近信息的能力,一定程度上能够提高大模型的回复准确度,减少幻觉。
+网页搜索功能旨在为大模型提供联网检索能力,以获取最近信息,一定程度上能够提高回复准确度,减少幻觉。
AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力。如果你不了解函数调用,请参考:[函数调用](/use/websearch)。
@@ -13,22 +13,28 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力
等等带有搜索意味的提示让大模型触发调用搜索工具。
-AstrBot 支持 3 种网页搜索源接入方式:`默认`、`Tavily`、`百度 AI 搜索`。
-
-前者使用 AstrBot 内置的网页搜索请求器请求 Google、Bing、搜狗搜索引擎,在能够使用 Google 的网络环境下表现最佳。**我们推荐使用 Tavily**。
+AstrBot 当前支持 4 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`。

-进入 `配置`,下拉找到网页搜索,您可选择 `default`(默认,不推荐) 或 `Tavily`。
-
-### default(不推荐)
-
-如果您的设备在国内并且有代理,可以开启代理并在 `管理面板-其他配置-HTTP代理` 填入 HTTP 代理地址以应用代理。
+进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索` 或 `Brave`。
### Tavily
前往 [Tavily](https://app.tavily.com/home) 得到 API Key,然后填写在相应的配置项。
+### BoCha
+
+前往 BoCha 平台获取 API Key,然后填写在相应的配置项。
+
+### 百度 AI 搜索
+
+前往百度千帆 APP Builder 获取 API Key,然后填写在相应的配置项。
+
+### Brave
+
+前往 Brave Search 获取 API Key,然后填写在相应的配置项。
+
如果您使用 Tavily 作为网页搜索源,在 AstrBot ChatUI 上将会获得更好的体验优化,包括引用来源展示等:
-
\ No newline at end of file
+
diff --git a/tests/test_backup.py b/tests/test_backup.py
index 9aa7817c0..28af6b7ce 100644
--- a/tests/test_backup.py
+++ b/tests/test_backup.py
@@ -17,9 +17,9 @@ from astrbot.core.backup import (
)
from astrbot.core.backup.exporter import AstrBotExporter
from astrbot.core.backup.importer import (
- DatabaseClearError,
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT,
AstrBotImporter,
+ DatabaseClearError,
ImportResult,
_get_major_version,
)
@@ -1017,6 +1017,8 @@ class TestModelMappings:
"conversations",
"personas",
"preferences",
+ "chatui_projects",
+ "session_project_relations",
"attachments",
]
for table in expected_tables:
diff --git a/tests/test_mattermost_adapter.py b/tests/test_mattermost_adapter.py
new file mode 100644
index 000000000..7fd13d35d
--- /dev/null
+++ b/tests/test_mattermost_adapter.py
@@ -0,0 +1,95 @@
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+import astrbot.api.message_components as Comp
+from astrbot.core.platform.sources.mattermost.client import MattermostClient
+from astrbot.core.platform.sources.mattermost.mattermost_adapter import (
+ MattermostPlatformAdapter,
+)
+from tests.fixtures.helpers import make_platform_config
+
+
+def _build_adapter() -> MattermostPlatformAdapter:
+ adapter = MattermostPlatformAdapter(
+ make_platform_config(
+ "mattermost",
+ id="test_mattermost",
+ mattermost_url="https://chat.example.com",
+ mattermost_bot_token="test_token",
+ mattermost_reconnect_delay=5.0,
+ ),
+ {},
+ asyncio.Queue(),
+ )
+ adapter.bot_self_id = "bot-id"
+ adapter.bot_username = "bot"
+ adapter._mention_pattern = adapter._build_mention_pattern(adapter.bot_username)
+ return adapter
+
+
+@pytest.mark.asyncio
+async def test_mattermost_convert_message_strips_leading_self_mention():
+ adapter = _build_adapter()
+
+ result = await adapter.convert_message(
+ post={
+ "id": "post-1",
+ "channel_id": "channel-1",
+ "user_id": "user-1",
+ "message": "@bot /help now",
+ "create_at": 1_700_000_000_000,
+ "file_ids": [],
+ },
+ data={
+ "channel_type": "O",
+ "sender_name": "alice",
+ },
+ )
+
+ assert result is not None
+ assert result.message_str == "/help now"
+ assert isinstance(result.message[0], Comp.At)
+ assert result.message[0].qq == "bot-id"
+ assert any(
+ isinstance(component, Comp.Plain) and component.text.strip() == "/help now"
+ for component in result.message
+ )
+
+
+@pytest.mark.asyncio
+async def test_mattermost_parse_post_attachments_maps_media_types(tmp_path):
+ client = MattermostClient("https://chat.example.com", "test_token")
+
+ file_infos = {
+ "img": {"name": "image.png", "mime_type": "image/png"},
+ "audio": {"name": "voice.ogg", "mime_type": "audio/ogg"},
+ "video": {"name": "clip.mp4", "mime_type": "video/mp4"},
+ "doc": {"name": "report.pdf", "mime_type": "application/pdf"},
+ }
+
+ client.get_file_info = AsyncMock(side_effect=lambda file_id: file_infos[file_id])
+ client.download_file = AsyncMock(return_value=b"payload")
+
+ with patch(
+ "astrbot.core.platform.sources.mattermost.client.get_astrbot_temp_path",
+ MagicMock(return_value=str(tmp_path)),
+ ):
+ components, temp_paths = await client.parse_post_attachments(
+ ["img", "audio", "video", "doc"]
+ )
+
+ assert len(components) == 4
+ assert isinstance(components[0], Comp.Image)
+ assert isinstance(components[1], Comp.Record)
+ assert isinstance(components[2], Comp.Video)
+ assert isinstance(components[3], Comp.File)
+ assert len(temp_paths) == 4
+
+ expected_names = ["image.png", "voice.ogg", "clip.mp4", "report.pdf"]
+ for temp_path, expected_name in zip(temp_paths, expected_names):
+ path = Path(temp_path)
+ assert path.exists()
+ assert path.name.endswith(Path(expected_name).suffix)
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index 60b289a17..1f6f27556 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -562,7 +562,9 @@ async def test_tool_result_includes_all_calltoolresult_content(
async def test_same_tool_consecutive_results_include_escalating_guidance(
runner, mock_tool_executor, mock_hooks
):
- provider = SequentialToolProvider(["test_tool"] * 5)
+ runner_cls = type(runner)
+ total_calls = runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD
+ provider = SequentialToolProvider(["test_tool"] * total_calls)
tool = FunctionTool(
name="test_tool",
description="测试工具",
@@ -584,16 +586,15 @@ async def test_same_tool_consecutive_results_include_escalating_guidance(
streaming=False,
)
- async for _ in runner.step_until_done(6):
+ async for _ in runner.step_until_done(total_calls + 1):
pass
tool_messages = [
m for m in runner.run_context.messages if getattr(m, "role", None) == "tool"
]
- assert len(tool_messages) == 5
+ assert len(tool_messages) == total_calls
tool_contents = [str(message.content) for message in tool_messages]
- runner_cls = type(runner)
level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
tool_name="test_tool",
streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD,
@@ -607,19 +608,33 @@ async def test_same_tool_consecutive_results_include_escalating_guidance(
streak=runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD,
)
- assert level_1_notice not in tool_contents[0]
- assert level_2_notice not in tool_contents[0]
- assert level_1_notice in tool_contents[1]
- assert level_2_notice in tool_contents[2]
- assert level_3_notice in tool_contents[4]
+ for streak, content in enumerate(tool_contents, start=1):
+ if streak < runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
+ assert level_1_notice not in content
+ assert level_2_notice not in content
+ assert level_3_notice not in content
+ elif streak < runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
+ assert level_1_notice in content
+ assert level_2_notice not in content
+ assert level_3_notice not in content
+ elif streak < runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD:
+ assert level_1_notice not in content
+ assert level_2_notice in content
+ assert level_3_notice not in content
+ else:
+ assert level_1_notice not in content
+ assert level_2_notice not in content
+ assert level_3_notice in content
@pytest.mark.asyncio
async def test_same_tool_streak_resets_after_switching_tools(
runner, mock_tool_executor, mock_hooks
):
+ runner_cls = type(runner)
+ repeated_after_reset = runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD
provider = SequentialToolProvider(
- ["test_tool", "other_tool", "test_tool", "test_tool"]
+ ["test_tool", "other_tool", *(["test_tool"] * repeated_after_reset)]
)
tool_a = FunctionTool(
name="test_tool",
@@ -648,16 +663,15 @@ async def test_same_tool_streak_resets_after_switching_tools(
streaming=False,
)
- async for _ in runner.step_until_done(5):
+ async for _ in runner.step_until_done(repeated_after_reset + 3):
pass
tool_messages = [
m for m in runner.run_context.messages if getattr(m, "role", None) == "tool"
]
- assert len(tool_messages) == 4
+ assert len(tool_messages) == repeated_after_reset + 2
tool_contents = [str(message.content) for message in tool_messages]
- runner_cls = type(runner)
level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
tool_name="test_tool",
streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD,
@@ -669,9 +683,20 @@ async def test_same_tool_streak_resets_after_switching_tools(
assert level_1_notice not in tool_contents[0]
assert level_1_notice not in tool_contents[1]
- assert level_1_notice not in tool_contents[2]
- assert level_2_notice not in tool_contents[2]
- assert level_1_notice in tool_contents[3]
+ assert level_2_notice not in tool_contents[0]
+ assert level_2_notice not in tool_contents[1]
+
+ repeated_contents = tool_contents[2:]
+ for streak_after_reset, content in enumerate(repeated_contents, start=1):
+ if streak_after_reset < runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
+ assert level_1_notice not in content
+ assert level_2_notice not in content
+ elif streak_after_reset < runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
+ assert level_1_notice in content
+ assert level_2_notice not in content
+ else:
+ assert level_1_notice not in content
+ assert level_2_notice in content
@pytest.mark.asyncio
diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py
new file mode 100644
index 000000000..3c03e618f
--- /dev/null
+++ b/tests/unit/test_func_tool_manager.py
@@ -0,0 +1,14 @@
+from astrbot.core import sp
+from astrbot.core.provider.func_tool_manager import FunctionToolManager
+from astrbot.core.tools.message_tools import SendMessageToUserTool
+
+
+def test_get_builtin_tool_by_class_returns_cached_instance():
+ manager = FunctionToolManager()
+
+ tool_by_class = manager.get_builtin_tool(SendMessageToUserTool)
+ tool_by_name = manager.get_builtin_tool("send_message_to_user")
+
+ assert tool_by_class is tool_by_name
+ assert manager.get_func("send_message_to_user") is tool_by_class
+ assert tool_by_class.name == "send_message_to_user"
diff --git a/tests/unit/test_kb_manager_resilience.py b/tests/unit/test_kb_manager_resilience.py
index 6c04b6fef..a4cfae7ee 100644
--- a/tests/unit/test_kb_manager_resilience.py
+++ b/tests/unit/test_kb_manager_resilience.py
@@ -266,7 +266,7 @@ async def test_ensure_vec_db_clears_stale_init_error(
mock_vec_db.close = AsyncMock()
with patch(
- "astrbot.core.knowledge_base.kb_helper.FaissVecDB",
+ "astrbot.core.db.vec_db.faiss_impl.vec_db.FaissVecDB",
return_value=mock_vec_db,
):
# Execute _ensure_vec_db