diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index ed25cd058..54cf0779f 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -13,7 +13,7 @@ jobs:
- name: checkout
uses: actions/checkout@v6
- name: Setup pnpm
- uses: pnpm/action-setup@v6.0.3
+ uses: pnpm/action-setup@v6.0.7
with:
version: 10.28.2
- name: Setup Node.js
diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml
index 521edcc41..09b2f5bed 100644
--- a/.github/workflows/dashboard_ci.yml
+++ b/.github/workflows/dashboard_ci.yml
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
- uses: pnpm/action-setup@v6.0.3
+ uses: pnpm/action-setup@v6.0.7
with:
version: 10.28.2
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8304e277f..c04543519 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -51,7 +51,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
- uses: pnpm/action-setup@v6.0.3
+ uses: pnpm/action-setup@v6.0.7
with:
version: 10.28.2
diff --git a/AGENTS.md b/AGENTS.md
index 695c338de..7de7832ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -248,3 +248,10 @@ chore: maintenance tasks
1. Create in `astrbot/builtin_stars/` or as plugin
2. Extend `Star` class
3. Use decorators: `@star.on_command()`, `@star.on_schedule()`, etc.
+
+## Release versions
+
+1. Replace current version name to specific version name.
+2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
+3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
+4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
\ No newline at end of file
diff --git a/README.md b/README.md
index b566b454d..3a6b56554 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
diff --git a/README_fr.md b/README_fr.md
index 4b9e978d4..a77c65721 100644
--- a/README_fr.md
+++ b/README_fr.md
@@ -11,7 +11,7 @@
diff --git a/README_ja.md b/README_ja.md
index 849fab695..c78b57106 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -11,7 +11,7 @@
diff --git a/README_ru.md b/README_ru.md
index 044120320..476ff6d7c 100644
--- a/README_ru.md
+++ b/README_ru.md
@@ -11,7 +11,7 @@
diff --git a/README_zh-TW.md b/README_zh-TW.md
index 2aaedfabb..fdeedfbc8 100644
--- a/README_zh-TW.md
+++ b/README_zh-TW.md
@@ -11,7 +11,7 @@
diff --git a/README_zh.md b/README_zh.md
index 1b55b171a..f07972637 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -9,7 +9,7 @@
Русский
diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py
index 3390fdeca..06108636b 100644
--- a/astrbot/builtin_stars/astrbot/main.py
+++ b/astrbot/builtin_stars/astrbot/main.py
@@ -1,5 +1,6 @@
import copy
import traceback
+from collections.abc import Iterable
from sys import maxsize
import astrbot.api.message_components as Comp
@@ -19,6 +20,13 @@ from astrbot.core.utils.session_waiter import (
from .long_term_memory import LongTermMemory
+def _iter_message_components(event: AstrMessageEvent):
+ messages = getattr(getattr(event, "message_obj", None), "message", None)
+ if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)):
+ return ()
+ return tuple(messages)
+
+
class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
@@ -134,8 +142,9 @@ class Main(star.Star):
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
async def on_message(self, event: AstrMessageEvent):
"""群聊记忆增强"""
+ message_components = _iter_message_components(event)
has_image_or_plain = False
- for comp in event.message_obj.message:
+ for comp in message_components:
if isinstance(comp, Plain) or isinstance(comp, Image):
has_image_or_plain = True
break
@@ -177,6 +186,13 @@ class Main(star.Star):
)
prompt = event.message_str
+ image_urls = []
+ for comp in message_components:
+ if isinstance(comp, Image):
+ try:
+ image_urls.append(await comp.convert_to_file_path())
+ except Exception:
+ logger.exception("主动回复处理图片失败")
if not conv:
logger.error("未找到对话,无法主动回复")
@@ -185,6 +201,7 @@ class Main(star.Star):
yield event.request_llm(
prompt=prompt,
session_id=event.session_id,
+ image_urls=image_urls,
conversation=conv,
)
except BaseException as e:
diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py
index 0aa205bf0..3a4247e39 100644
--- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py
+++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py
@@ -73,6 +73,7 @@ async def _clear_third_party_agent_runner_state(
context: 星尘上下文。
session_id: 会话 ID (unified_msg_origin)。
provider_type: 提供商类型 (如 deerflow)。
+
"""
provider_config = context.provider_manager.get_provider_config_by_id(
provider_type,
@@ -349,7 +350,7 @@ class ConversationCommands:
if not cid:
message.set_result(
MessageEventResult().message(
- "❌ You are not in a conversation. Use /new to create one."
+ "❌ You are not in a conversation. Use /new to create one.",
),
)
return
@@ -373,14 +374,14 @@ class ConversationCommands:
).where(
col(ProviderStat.agent_type) == "internal",
col(ProviderStat.conversation_id) == cid,
- )
+ ),
)
stats = result.one()
if stats.record_count == 0:
message.set_result(
MessageEventResult().message(
- "📊 No stats available for this conversation yet."
+ "📊 No stats available for this conversation yet.",
),
)
return
diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py
index 28ee08a40..bf4d023cf 100644
--- a/astrbot/cli/__init__.py
+++ b/astrbot/cli/__init__.py
@@ -1 +1 @@
-__version__ = "4.24.2"
+__version__ = "4.25.1"
diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py
index e2cd725e7..6d50d19af 100644
--- a/astrbot/cli/commands/cmd_conf.py
+++ b/astrbot/cli/commands/cmd_conf.py
@@ -23,6 +23,7 @@ from astrbot.core.utils.auth_password import (
_is_argon2_hash,
_is_pbkdf2_hash,
hash_dashboard_password,
+ hash_legacy_dashboard_password,
is_legacy_dashboard_password,
validate_dashboard_password,
)
@@ -71,8 +72,8 @@ def _validate_dashboard_password(value: str) -> str:
validate_dashboard_password(value)
except ValueError as e:
raise click.ClickException(str(e)) from e
- # Return the canonical stored representation.
- return hash_dashboard_password(value)
+ # Return the plaintext value; callers hash it before storage.
+ return value
def _validate_timezone(value: str) -> str:
@@ -200,10 +201,16 @@ def set_dashboard_credentials(
"Please provide the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string.",
)
+ validated = _validate_dashboard_password(password_hash)
+ _set_nested_item(
+ config,
+ "dashboard.pbkdf2_password",
+ hash_dashboard_password(validated),
+ )
_set_nested_item(
config,
"dashboard.password",
- _validate_dashboard_password(password_hash),
+ hash_legacy_dashboard_password(validated),
)
@@ -237,7 +244,19 @@ def set_config(key: str, value: str) -> None:
old_value = ""
validated_value = CONFIG_VALIDATORS[key](value)
- _set_nested_item(config, key, validated_value)
+ if key == "dashboard.password":
+ _set_nested_item(
+ config,
+ "dashboard.pbkdf2_password",
+ hash_dashboard_password(validated_value),
+ )
+ _set_nested_item(
+ config,
+ "dashboard.password",
+ hash_legacy_dashboard_password(validated_value),
+ )
+ else:
+ _set_nested_item(config, key, validated_value)
_save_config(config)
click.echo(f"Config updated: {key}")
diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py
index bffb5f99b..4d4e51270 100644
--- a/astrbot/cli/commands/cmd_init.py
+++ b/astrbot/cli/commands/cmd_init.py
@@ -13,6 +13,18 @@ from astrbot.core.utils.astrbot_path import astrbot_paths
from .cmd_conf import ensure_config_file, set_dashboard_credentials
+DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
+
+
+def _initialize_config_from_env(astrbot_root: Path) -> None:
+ if DASHBOARD_INITIAL_PASSWORD_ENV not in os.environ:
+ return
+
+ from astrbot.core.config.astrbot_config import AstrBotConfig
+
+ AstrBotConfig(config_path=str(astrbot_root / "data" / "cmd_config.json"))
+ click.echo("Initialized data/cmd_config.json with dashboard initial password.")
+
async def initialize_astrbot(
astrbot_root: Path,
@@ -51,6 +63,9 @@ async def initialize_astrbot(
path.mkdir(parents=True, exist_ok=True)
status = "Created" if not path.exists() else "Exists"
click.echo(f" [{status}] {name.title()}: {path}")
+
+ _initialize_config_from_env(astrbot_root)
+
config_path = astrbot_root / "data" / "cmd_config.json"
if not config_path.exists():
config_path.write_text(
diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py
index e8bf20cdb..985b4f235 100644
--- a/astrbot/core/agent/message.py
+++ b/astrbot/core/agent/message.py
@@ -38,7 +38,9 @@ class ContentPart(BaseModel):
@classmethod
def __get_pydantic_core_schema__(
- cls, source_type: Any, handler: GetCoreSchemaHandler
+ cls,
+ source_type: Any,
+ handler: GetCoreSchemaHandler,
) -> core_schema.CoreSchema:
# If we're dealing with the base ContentPart class, use custom validation
if cls.__name__ == "ContentPart":
@@ -50,12 +52,12 @@ class ContentPart(BaseModel):
# if it's a dict with a type field, dispatch to the appropriate subclass
if isinstance(value, dict) and "type" in value:
- type_value: Any | None = cast(dict[str, Any], value).get("type")
+ type_value: Any | None = cast("dict[str, Any]", value).get("type")
if not isinstance(type_value, str):
raise ValueError(f"Cannot validate {value} as ContentPart")
target_class = cls.__content_part_registry[type_value]
part = target_class.model_validate(value)
- if cast(dict[str, Any], value).get("_no_save"):
+ if cast("dict[str, Any]", value).get("_no_save"):
part._no_save = True
return part
@@ -79,8 +81,7 @@ class ContentPart(BaseModel):
class TextPart(ContentPart):
- """
- >>> TextPart(text="Hello, world!").model_dump()
+ """>>> TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
"""
@@ -89,8 +90,7 @@ class TextPart(ContentPart):
class ThinkPart(ContentPart):
- """
- >>> ThinkPart(think="I think I need to think about this.").model_dump()
+ """>>> ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
"""
@@ -111,8 +111,7 @@ class ThinkPart(ContentPart):
class ImageURLPart(ContentPart):
- """
- >>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
+ """>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
"""
@@ -127,8 +126,7 @@ class ImageURLPart(ContentPart):
class AudioURLPart(ContentPart):
- """
- >>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
+ """>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
"""
@@ -143,8 +141,7 @@ class AudioURLPart(ContentPart):
class ToolCall(BaseModel):
- """
- A tool call requested by the assistant.
+ """A tool call requested by the assistant.
>>> ToolCall(
... id="123",
@@ -233,7 +230,7 @@ class Message(BaseModel):
# other all cases: content is required
if self.content is None:
raise ValueError(
- "content is required unless role='assistant' and tool_calls is not None"
+ "content is required unless role='assistant' and tool_calls is not None",
)
return self
@@ -357,6 +354,8 @@ def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
dumped.append(message_data)
if message._checkpoint_after is not None:
dumped.append(
- CheckpointMessageSegment(content=message._checkpoint_after).model_dump()
+ CheckpointMessageSegment(
+ content=message._checkpoint_after,
+ ).model_dump(),
)
return dumped
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 132ca8192..544443377 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -83,7 +83,8 @@ class _HandleFunctionToolsResult:
@classmethod
def from_tool_call_result_blocks(
- cls, blocks: list[ToolCallMessageSegment]
+ cls,
+ blocks: list[ToolCallMessageSegment],
) -> "_HandleFunctionToolsResult":
return cls(kind="tool_call_result_blocks", tool_call_result_blocks=blocks)
@@ -188,7 +189,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
- )
+ ),
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
@@ -400,7 +401,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
return content
estimated_tokens = self._tool_result_token_counter.count_tokens(
- [Message(role="tool", content=content, tool_call_id=tool_call_id)]
+ [Message(role="tool", content=content, tool_call_id=tool_call_id)],
)
if estimated_tokens <= self.TOOL_RESULT_MAX_ESTIMATED_TOKENS:
return content
@@ -445,7 +446,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
preview = content
while preview:
estimated_tokens = self._tool_result_token_counter.count_tokens(
- [Message(role="tool", content=preview, tool_call_id=tool_call_id)]
+ [Message(role="tool", content=preview, tool_call_id=tool_call_id)],
)
if estimated_tokens <= self.TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS:
return preview
@@ -456,7 +457,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
return preview
async def _iter_llm_responses(
- self, *, include_model: bool = True
+ self,
+ *,
+ include_model: bool = True,
) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
@@ -512,7 +515,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
with attempt:
try:
async for resp in self._iter_llm_responses(
- include_model=idx == 0
+ include_model=idx == 0,
):
if resp.is_chunk:
has_stream_output = True
@@ -708,7 +711,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
self._simple_print_message_role("[BefCompact]")
self.run_context.messages = await self.context_manager.process(
- self.run_context.messages, trusted_token_usage=token_usage
+ self.run_context.messages,
+ trusted_token_usage=token_usage,
)
self._simple_print_message_role("[AftCompact]")
@@ -811,7 +815,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
if not llm_resp.tools_call_name:
logger.warning(
- "skills_like tool re-query returned no tool calls; fallback to assistant response."
+ "skills_like tool re-query returned no tool calls; fallback to assistant response.",
)
if llm_resp.result_chain:
yield AgentResponse(
@@ -863,7 +867,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
- )
+ ),
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
@@ -878,7 +882,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
# record the assistant message with tool calls
self.run_context.messages.extend(
- tool_calls_result.to_openai_messages_model()
+ tool_calls_result.to_openai_messages_model(),
)
# If there are cached images and the model supports image input,
@@ -891,35 +895,37 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
image_parts = []
for cached_img in cached_images:
img_data = tool_image_cache.get_image_base64_by_path(
- cached_img.file_path, cached_img.mime_type
+ cached_img.file_path,
+ cached_img.mime_type,
)
if img_data:
base64_data, mime_type = img_data
image_parts.append(
TextPart(
- text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']"
- )
+ text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']",
+ ),
)
image_parts.append(
ImageURLPart(
image_url=ImageURLPart.ImageURL(
url=f"data:{mime_type};base64,{base64_data}",
id=cached_img.file_path,
- )
- )
+ ),
+ ),
)
if image_parts:
self.run_context.messages.append(
- Message(role="user", content=image_parts)
+ Message(role="user", content=image_parts),
)
logger.debug(
- f"Appended {len(cached_images)} cached image(s) to context for LLM review"
+ f"Appended {len(cached_images)} cached image(s) to context for LLM review",
)
self.req.append_tool_calls_result(tool_calls_result)
async def step_until_done(
- self, max_step: int
+ self,
+ max_step: int,
) -> T.AsyncGenerator[AgentResponse, None]:
"""Process steps until the agent is done."""
step_count = 0
@@ -931,7 +937,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
- f"Agent reached max steps ({max_step}), forcing a final response."
+ f"Agent reached max steps ({max_step}), forcing a final response.",
)
# 拔掉所有工具
if self.req:
@@ -941,7 +947,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
Message(
role="user",
content=self.MAX_STEPS_REACHED_PROMPT,
- )
+ ),
)
# 再执行最后一步
async for resp in self.step():
@@ -982,10 +988,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
- }
- )
+ },
+ ),
],
- )
+ ),
)
try:
if not req.func_tool:
@@ -1003,6 +1009,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool = req.func_tool.get_tool(func_tool_name)
available_tools = req.func_tool.names()
+ # Some API may return None for tools with no parameters
+ if func_tool_args is None:
+ func_tool_args = {}
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
if not func_tool:
@@ -1084,11 +1093,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
result_parts.append(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
- f"with type='image' and path='{cached_img.file_path}'."
+ f"with type='image' and path='{cached_img.file_path}'.",
)
# Yield image info for LLM visibility (will be handled in step())
yield _HandleFunctionToolsResult.from_cached_image(
- cached_img
+ cached_img,
)
elif isinstance(content_item, EmbeddedResource):
resource = content_item.resource
@@ -1110,15 +1119,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
result_parts.append(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
- f"with type='image' and path='{cached_img.file_path}'."
+ f"with type='image' and path='{cached_img.file_path}'.",
)
# Yield image info for LLM visibility
yield _HandleFunctionToolsResult.from_cached_image(
- cached_img
+ cached_img,
)
else:
result_parts.append(
- "The tool has returned a data type that is not supported."
+ "The tool has returned a data type that is not supported.",
)
if result_parts:
inline_result = "\n\n".join(result_parts)
@@ -1130,7 +1139,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
inline_result
+ self._build_repeated_tool_call_guidance(
- func_tool_name, tool_call_streak
+ func_tool_name,
+ tool_call_streak,
),
)
@@ -1139,7 +1149,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 这里我们将直接结束 Agent Loop
# 发送消息逻辑在 ToolExecutor 中处理了
logger.warning(
- f"{func_tool_name} 没有返回值,或者已将结果直接发送给用户。"
+ f"{func_tool_name} 没有返回值,或者已将结果直接发送给用户。",
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
@@ -1147,7 +1157,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
"The tool has no return value, or has sent the result directly to the user."
+ self._build_repeated_tool_call_guidance(
- func_tool_name, tool_call_streak
+ func_tool_name,
+ tool_call_streak,
),
)
else:
@@ -1159,7 +1170,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
+ self._build_repeated_tool_call_guidance(
- func_tool_name, tool_call_streak
+ func_tool_name,
+ tool_call_streak,
),
)
@@ -1180,7 +1192,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
f"error: {e!s}"
+ self._build_repeated_tool_call_guidance(
- func_tool_name, tool_call_streak
+ func_tool_name,
+ tool_call_streak,
),
)
@@ -1196,17 +1209,17 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
- }
- )
+ },
+ ),
],
- )
+ ),
)
logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
# 处理函数调用响应
if tool_call_result_blocks:
yield _HandleFunctionToolsResult.from_tool_call_result_blocks(
- tool_call_result_blocks
+ tool_call_result_blocks,
)
def _build_tool_requery_context(
@@ -1222,7 +1235,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
elif isinstance(msg, dict):
contexts.append(copy.deepcopy(msg))
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
- tool_names=", ".join(tool_names)
+ tool_names=", ".join(tool_names),
)
if extra_instruction:
instruction = f"{instruction}\n{extra_instruction}"
@@ -1265,7 +1278,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if isinstance(self._tool_schema_param_set, ToolSet):
param_subset = self._build_tool_subset(
- self._tool_schema_param_set, tool_names
+ self._tool_schema_param_set,
+ tool_names,
)
if param_subset.tools and tool_names:
contexts = self._build_tool_requery_context(tool_names)
@@ -1289,7 +1303,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
and not self._has_meaningful_assistant_reply(llm_resp)
):
logger.warning(
- "skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
+ "skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction.",
)
repair_contexts = self._build_tool_requery_context(
tool_names,
@@ -1348,7 +1362,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
- )
+ ),
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
@@ -1381,7 +1395,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if self._is_stop_requested():
await self._close_executor(executor)
raise _ToolExecutionInterrupted(
- "Tool execution interrupted before reading the next tool result."
+ "Tool execution interrupted before reading the next tool result.",
)
next_result_task = asyncio.create_task(anext(executor))
@@ -1401,7 +1415,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
await self._close_executor(executor)
raise _ToolExecutionInterrupted(
- "Tool execution interrupted by a stop request."
+ "Tool execution interrupted by a stop request.",
)
try:
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index 6c37f1a83..a7ad4b153 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -237,6 +237,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
Returns:
Dict mapping tool name to FunctionTool instance.
+
"""
from astrbot.core.computer.computer_tool_provider import (
ComputerToolProvider,
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index da590f5be..b91ec3a23 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -112,7 +112,8 @@ from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
@dataclass(slots=True)
class MainAgentBuildConfig:
"""The main agent build configuration.
- Most of the configs can be found in the cmd_config.json"""
+ Most of the configs can be found in the cmd_config.json
+ """
tool_call_timeout: int
"""The timeout (in seconds) for a tool call.
@@ -178,7 +179,8 @@ class MainAgentBuildResult:
def _select_provider(
- event: AstrMessageEvent, plugin_context: Context
+ event: AstrMessageEvent,
+ plugin_context: Context,
) -> Provider | None:
"""Select chat provider for the event."""
sel_provider = event.get_extra("selected_provider")
@@ -188,7 +190,8 @@ def _select_provider(
logger.error("未找到指定的提供商: %s。", sel_provider)
if not isinstance(provider, Provider):
logger.error(
- "选择的提供商类型无效(%s),跳过 LLM 请求处理。", type(provider)
+ "选择的提供商类型无效(%s),跳过 LLM 请求处理。",
+ type(provider),
)
return None
return provider
@@ -200,7 +203,8 @@ def _select_provider(
async def _get_session_conv(
- event: AstrMessageEvent, plugin_context: Context
+ event: AstrMessageEvent,
+ plugin_context: Context,
) -> Conversation:
conv_mgr = plugin_context.conversation_manager
umo = event.unified_msg_origin
@@ -223,7 +227,7 @@ async def _apply_kb(
config: MainAgentBuildConfig,
) -> None:
if not config.kb_agentic_mode:
- if req.prompt is None:
+ if req.prompt is None or not req.prompt.strip():
return
try:
kb_result = await retrieve_knowledge_base(
@@ -244,8 +248,8 @@ async def _apply_kb(
req.func_tool = ToolSet()
req.func_tool.add_tool(
plugin_context.get_llm_tool_manager().get_builtin_tool(
- KnowledgeBaseQueryTool
- )
+ KnowledgeBaseQueryTool,
+ ),
)
@@ -280,7 +284,7 @@ async def _apply_file_extract(
config.file_extract_msh_api_key,
)
for file_path in file_paths
- ]
+ ],
)
else:
logger.error("Unsupported file extract provider: %s", config.file_extract_prov)
@@ -429,7 +433,8 @@ async def _ensure_persona_and_skills(
)
set_persona_custom_error_message_on_event(
- event, extract_persona_custom_error_message_from_persona(persona)
+ event,
+ extract_persona_custom_error_message_from_persona(persona),
)
# Ensure system_prompt is a string before any +=
@@ -515,7 +520,7 @@ async def _ensure_persona_and_skills(
tool.name
for tool in tmgr.func_list
if not isinstance(tool, HandoffTool)
- ]
+ ],
)
continue
if not isinstance(tools, list):
@@ -607,7 +612,7 @@ async def _ensure_img_caption(
)
if caption:
req.extra_user_content_parts.append(
- TextPart(text=f"{caption}")
+ TextPart(text=f"{caption}"),
)
req.image_urls = []
except Exception as exc: # noqa: BLE001
@@ -619,19 +624,19 @@ async def _ensure_img_caption(
def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> None:
req.extra_user_content_parts.append(
- TextPart(text=f"[Image Attachment in quoted message: path {image_path}]")
+ TextPart(text=f"[Image Attachment in quoted message: path {image_path}]"),
)
def _append_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
req.extra_user_content_parts.append(
- TextPart(text=f"[Audio Attachment: path {audio_path}]")
+ TextPart(text=f"[Audio Attachment: path {audio_path}]"),
)
def _append_quoted_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
req.extra_user_content_parts.append(
- TextPart(text=f"[Audio Attachment in quoted message: path {audio_path}]")
+ TextPart(text=f"[Audio Attachment in quoted message: path {audio_path}]"),
)
@@ -787,7 +792,7 @@ async def _process_quote_message(
)
if llm_resp.completion_text:
content_parts.append(
- f"[Image Caption in quoted message]: {llm_resp.completion_text}"
+ f"[Image Caption in quoted message]: {llm_resp.completion_text}",
)
else:
logger.warning("No provider found for image captioning in quote.")
@@ -798,7 +803,7 @@ async def _process_quote_message(
compress_path
and compress_path != path
and os.path.exists(compress_path)
- ): # noqa: PNT120
+ ):
try:
os.remove(compress_path)
except Exception as exc: # noqa: BLE001
@@ -860,7 +865,7 @@ async def _decorate_llm_request(
config: MainAgentBuildConfig,
) -> None:
cfg = config.provider_settings or plugin_context.get_config(
- umo=event.unified_msg_origin
+ umo=event.unified_msg_origin,
).get("provider_settings", {})
_apply_prompt_prefix(req, cfg)
@@ -926,7 +931,9 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
async def _handle_webchat(
- event: AstrMessageEvent, req: ProviderRequest, prov: Provider
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ prov: Provider,
) -> None:
from astrbot.core import db_helper
@@ -961,7 +968,9 @@ async def _handle_webchat(
if not title or "" in title:
return
logger.info(
- "Generated chatui title for session %s: %s", chatui_session_id, title
+ "Generated chatui title for session %s: %s",
+ chatui_session_id,
+ title,
)
await db_helper.update_platform_session(
session_id=chatui_session_id,
@@ -1098,7 +1107,8 @@ async def _apply_web_search_tools(
def _get_compress_provider(
- config: MainAgentBuildConfig, plugin_context: Context
+ config: MainAgentBuildConfig,
+ plugin_context: Context,
) -> Provider | None:
if not config.llm_compress_provider_id:
return None
@@ -1121,12 +1131,14 @@ def _get_compress_provider(
def _get_fallback_chat_providers(
- provider: Provider, plugin_context: Context, provider_settings: dict
+ provider: Provider,
+ plugin_context: Context,
+ provider_settings: dict,
) -> list[Provider]:
fallback_ids = provider_settings.get("fallback_chat_models", [])
if not isinstance(fallback_ids, list):
logger.warning(
- "fallback_chat_models setting is not a list, skip fallback providers."
+ "fallback_chat_models setting is not a list, skip fallback providers.",
)
return []
@@ -1189,7 +1201,7 @@ async def build_main_agent(
if sel_model := event.get_extra("selected_model"):
req.model = sel_model
if config.provider_wake_prefix and not event.message_str.startswith(
- config.provider_wake_prefix
+ config.provider_wake_prefix,
):
return None
@@ -1207,7 +1219,7 @@ async def build_main_agent(
event.track_temporary_local_file(image_path)
req.image_urls.append(image_path)
req.extra_user_content_parts.append(
- TextPart(text=f"[Image Attachment: path {image_path}]")
+ TextPart(text=f"[Image Attachment: path {image_path}]"),
)
elif isinstance(comp, Record):
audio_path = await comp.convert_to_file_path()
@@ -1218,8 +1230,8 @@ async def build_main_agent(
file_name = comp.name or os.path.basename(file_path)
req.extra_user_content_parts.append(
TextPart(
- text=f"[File Attachment: name {file_name}, path {file_path}]"
- )
+ text=f"[File Attachment: name {file_name}, path {file_path}]",
+ ),
)
elif isinstance(comp, Video):
await _append_video_attachment(req, comp)
@@ -1228,7 +1240,7 @@ async def build_main_agent(
comp for comp in event.message_obj.message if isinstance(comp, Reply)
]
quoted_message_settings = _get_quoted_message_parser_settings(
- config.provider_settings
+ config.provider_settings,
)
fallback_quoted_image_count = 0
for comp in reply_comps:
@@ -1258,8 +1270,8 @@ async def build_main_agent(
text=(
f"[File Attachment in quoted message: "
f"name {file_name}, path {file_path}]"
- )
- )
+ ),
+ ),
)
elif isinstance(reply_comp, Video):
await _append_video_attachment(req, reply_comp, quoted=True)
@@ -1273,7 +1285,7 @@ async def build_main_agent(
event,
comp,
settings=quoted_message_settings,
- )
+ ),
)
remaining_limit = max(
config.max_quoted_fallback_images
@@ -1326,8 +1338,8 @@ async def build_main_agent(
"The user is asking in a side thread about this selected "
"excerpt from the previous assistant answer:\n"
f"{thread_selected_text.strip()}"
- )
- )
+ ),
+ ),
)
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
req.audio_urls = normalize_and_dedupe_strings(req.audio_urls)
@@ -1376,8 +1388,8 @@ async def build_main_agent(
req.func_tool = ToolSet()
req.func_tool.add_tool(
plugin_context.get_llm_tool_manager().get_builtin_tool(
- "send_message_to_user"
- )
+ "send_message_to_user",
+ ),
)
if provider.provider_config.get("max_context_tokens", 0) <= 0:
@@ -1433,7 +1445,9 @@ async def build_main_agent(
enforce_max_turns=config.max_context_length,
tool_schema_mode=config.tool_schema_mode,
fallback_providers=_get_fallback_chat_providers(
- provider, plugin_context, config.provider_settings
+ provider,
+ plugin_context,
+ config.provider_settings,
),
tool_result_overflow_dir=(
get_astrbot_system_tmp_path()
diff --git a/astrbot/core/computer/booters/base.py b/astrbot/core/computer/booters/base.py
index d77892152..9dbed2823 100644
--- a/astrbot/core/computer/booters/base.py
+++ b/astrbot/core/computer/booters/base.py
@@ -58,7 +58,6 @@ class ComputerBooter(abc.ABC):
ShipyardNeoBooter). The default implementation ignores
them.
"""
- ...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to the computer.
diff --git a/astrbot/core/computer/booters/bwrap.py b/astrbot/core/computer/booters/bwrap.py
index 968e7b396..5e00a00e1 100644
--- a/astrbot/core/computer/booters/bwrap.py
+++ b/astrbot/core/computer/booters/bwrap.py
@@ -297,7 +297,10 @@ class HostBackedFileSystemComponent(FileSystemComponent):
cmd = ["grep", "-r", pattern, p]
result = await asyncio.to_thread(
- subprocess.run, cmd, capture_output=True, text=True
+ subprocess.run,
+ cmd,
+ capture_output=True,
+ text=True,
)
return {
"success": True,
diff --git a/astrbot/core/computer/booters/cua.py b/astrbot/core/computer/booters/cua.py
index 151b4c0e0..9c989a06b 100644
--- a/astrbot/core/computer/booters/cua.py
+++ b/astrbot/core/computer/booters/cua.py
@@ -58,7 +58,7 @@ async def _write_base64_via_shell(
"pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.stdin.read()))"
)
return await shell.exec(
- f"python3 -c {shlex.quote(decoder)} {shlex.quote(path)} <<'EOF'\n{encoded}\nEOF"
+ f"python3 -c {shlex.quote(decoder)} {shlex.quote(path)} <<'EOF'\n{encoded}\nEOF",
)
@@ -223,7 +223,7 @@ def _missing_component_method_error(
candidates = ", ".join(f"{component_name}.{name}" for name in names)
return RuntimeError(
f"CUA sandbox does not provide any of: {candidates}. "
- "Please check the installed CUA SDK version and sandbox backend."
+ "Please check the installed CUA SDK version and sandbox backend.",
)
@@ -344,7 +344,9 @@ class CuaPythonComponent(PythonComponent):
self._python_exec = None
if python is not None:
self._python_exec = getattr(python, "exec", None) or getattr(
- python, "run", None
+ python,
+ "run",
+ None,
)
async def exec(
@@ -394,7 +396,9 @@ def _write_result(path: str, result: dict[str, Any]) -> dict[str, Any]:
class CuaFileSystemComponent(FileSystemComponent):
def __init__(
- self, sandbox: Any, os_type: str = CUA_DEFAULT_CONFIG["os_type"]
+ self,
+ sandbox: Any,
+ os_type: str = CUA_DEFAULT_CONFIG["os_type"],
) -> None:
self._shell = CuaShellComponent(sandbox, os_type=os_type)
self._fs_components = _resolve_files_components(sandbox)
@@ -420,19 +424,21 @@ class CuaFileSystemComponent(FileSystemComponent):
limit: int | None = None,
) -> dict[str, Any]:
read_file = _resolve_files_method(
- self._fs_components, ("read_file", "read_text")
+ self._fs_components,
+ ("read_file", "read_text"),
)
if read_file is None:
return await self._fallback.read_file(path, encoding, offset, limit)
- else:
- content = await _maybe_await(read_file(path))
+ content = await _maybe_await(read_file(path))
if isinstance(content, bytes):
content = content.decode(encoding, errors="replace")
return {
"success": True,
"path": path,
"content": _slice_content_by_lines(
- str(content), offset=offset, limit=limit
+ str(content),
+ offset=offset,
+ limit=limit,
),
}
@@ -445,22 +451,22 @@ class CuaFileSystemComponent(FileSystemComponent):
) -> dict[str, Any]:
_ = mode
write_file = _resolve_files_method(
- self._fs_components, ("write_file", "write_text")
+ self._fs_components,
+ ("write_file", "write_text"),
)
if write_file is None:
return await self._fallback.write_file(path, content, mode, encoding)
- else:
- await _maybe_await(write_file(path, content))
+ await _maybe_await(write_file(path, content))
return {"success": True, "path": path}
async def delete_file(self, path: str) -> dict[str, Any]:
delete = _resolve_files_method(
- self._fs_components, ("delete", "delete_file", "remove")
+ self._fs_components,
+ ("delete", "delete_file", "remove"),
)
if delete is None:
return await self._fallback.delete_file(path)
- else:
- await _maybe_await(delete(path))
+ await _maybe_await(delete(path))
return {"success": True, "path": path}
async def list_dir(
@@ -547,7 +553,9 @@ class _PosixShellFileSystem(FileSystemComponent):
"success": True,
"path": path,
"content": _slice_content_by_lines(
- str(result.get("stdout", "")), offset=offset, limit=limit
+ str(result.get("stdout", "")),
+ offset=offset,
+ limit=limit,
),
}
@@ -562,7 +570,9 @@ class _PosixShellFileSystem(FileSystemComponent):
if error := self._ensure_posix(path):
return error
result = await _write_base64_via_shell(
- self._shell, path, content.encode(encoding)
+ self._shell,
+ path,
+ content.encode(encoding),
)
return _write_result(path, result)
@@ -628,7 +638,8 @@ class CuaGUIComponent(GUIComponent):
self._click = _resolve_component_method(mouse, "click")
self._type_text = _resolve_component_method(keyboard, "type")
self._press_key = _resolve_component_method(
- keyboard, ("press", "key_press", "press_key")
+ keyboard,
+ ("press", "key_press", "press_key"),
)
async def screenshot(self, path: str | None = None) -> dict[str, Any]:
@@ -661,7 +672,8 @@ class CuaGUIComponent(GUIComponent):
async def press_key(self, key: str) -> dict[str, Any]:
if self._press_key is None:
raise _missing_component_method_error(
- "keyboard", ("press", "key_press", "press_key")
+ "keyboard",
+ ("press", "key_press", "press_key"),
)
result = await _maybe_await(self._press_key(key))
payload = _maybe_model_dump(result)
@@ -733,7 +745,7 @@ class CuaBooter(ComputerBooter):
except ImportError as exc:
raise RuntimeError(
"CUA sandbox support requires the optional `cua` package. "
- "Install it with `pip install cua` in the AstrBot environment."
+ "Install it with `pip install cua` in the AstrBot environment.",
) from exc
image_obj = self._build_image(Image)
@@ -839,7 +851,7 @@ class CuaBooter(ComputerBooter):
sandbox = None if self._runtime is None else self._runtime.sandbox
if sandbox is not None and hasattr(sandbox, "upload_file"):
return _maybe_model_dump(
- await sandbox.upload_file(str(local_path), file_name)
+ await sandbox.upload_file(str(local_path), file_name),
)
files_components = () if sandbox is None else _resolve_files_components(sandbox)
upload = _resolve_files_method(files_components, "upload")
@@ -853,7 +865,9 @@ class CuaBooter(ComputerBooter):
if not _is_posix_os_type(self.os_type):
return _non_posix_filesystem_result(file_name, self.os_type)
result = await _write_base64_via_shell(
- self.shell, file_name, local_path.read_bytes()
+ self.shell,
+ file_name,
+ local_path.read_bytes(),
)
return {
"success": not bool(result.get("stderr")),
diff --git a/astrbot/core/computer/booters/cua_defaults.py b/astrbot/core/computer/booters/cua_defaults.py
index 4c506154a..a36c6e654 100644
--- a/astrbot/core/computer/booters/cua_defaults.py
+++ b/astrbot/core/computer/booters/cua_defaults.py
@@ -2,6 +2,7 @@ CUA_DEFAULT_CONFIG = {
"image": "linux",
"os_type": "linux",
"ttl": 3600,
+ "idle_timeout": 0,
"telemetry_enabled": False,
"local": True,
"api_key": "",
diff --git a/astrbot/core/computer/booters/shipyard.py b/astrbot/core/computer/booters/shipyard.py
index f0c6935cb..495cd6ac5 100644
--- a/astrbot/core/computer/booters/shipyard.py
+++ b/astrbot/core/computer/booters/shipyard.py
@@ -107,13 +107,18 @@ class ShipyardShellWrapper:
class ShipyardFileSystemWrapper:
def __init__(
- self, _shipyard_fs: FileSystemComponent, _shipyard_shell: ShellComponent
+ self,
+ _shipyard_fs: FileSystemComponent,
+ _shipyard_shell: ShellComponent,
):
self._fs = _shipyard_fs
self._shell = _shipyard_shell
async def create_file(
- self, path: str, content: str = "", mode: int = 420
+ self,
+ path: str,
+ content: str = "",
+ mode: int = 420,
) -> dict[str, Any]:
return await self._fs.create_file(path=path, content=content, mode=mode)
@@ -125,18 +130,30 @@ class ShipyardFileSystemWrapper:
limit: int | None = None,
) -> dict[str, Any]:
return await self._fs.read_file(
- path=path, encoding=encoding, offset=offset, limit=limit
+ path=path,
+ encoding=encoding,
+ offset=offset,
+ limit=limit,
)
async def write_file(
- self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
+ self,
+ path: str,
+ content: str,
+ mode: str = "w",
+ encoding: str = "utf-8",
) -> dict[str, Any]:
return await self._fs.write_file(
- path=path, content=content, mode=mode, encoding=encoding
+ path=path,
+ content=content,
+ mode=mode,
+ encoding=encoding,
)
async def list_dir(
- self, path: str = ".", show_hidden: bool = False
+ self,
+ path: str = ".",
+ show_hidden: bool = False,
) -> dict[str, Any]:
return await self._fs.list_dir(path=path, show_hidden=show_hidden)
diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py
index b8cd2deb3..f73937d5c 100644
--- a/astrbot/core/computer/booters/shipyard_neo.py
+++ b/astrbot/core/computer/booters/shipyard_neo.py
@@ -27,7 +27,7 @@ try:
from shipyard_neo.sandbox import Sandbox
except ImportError:
logger.warning(
- "shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it."
+ "shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it.",
)
@@ -278,12 +278,12 @@ class ShipyardNeoBooter(ComputerBooter):
self,
endpoint_url: str,
access_token: str,
- profile: str = DEFAULT_PROFILE,
+ profile: str = "",
ttl: int = 3600,
) -> None:
self._endpoint_url = endpoint_url
self._access_token = access_token
- self._profile = profile
+ self._profile = profile.strip() if profile else ""
self._ttl = ttl
self._client: Any = None
self._sandbox: Any = None
@@ -350,6 +350,10 @@ class ShipyardNeoBooter(ComputerBooter):
access_token=self._access_token,
)
await self._client.__aenter__()
+
+ # Resolve profile: user-specified > smart selection > default.
+ # An empty profile means auto-select; any non-empty profile must be
+ # honoured as an explicit choice, including "python-default".
resolved_profile = await self._resolve_profile(self._client)
self._sandbox = await self._client.create_sandbox(
profile=resolved_profile,
@@ -414,7 +418,7 @@ class ShipyardNeoBooter(ComputerBooter):
del_err,
)
raise RuntimeError(
- f"Sandbox {sandbox_id} is in terminal state: {status}"
+ f"Sandbox {sandbox_id} is in terminal state: {status}",
)
remaining = deadline - asyncio.get_running_loop().time()
@@ -436,7 +440,7 @@ class ShipyardNeoBooter(ComputerBooter):
)
raise TimeoutError(
f"Sandbox {sandbox_id} did not become ready within "
- f"{READINESS_TIMEOUT}s (last status: {status})"
+ f"{READINESS_TIMEOUT}s (last status: {status})",
)
logger.debug(
@@ -450,7 +454,7 @@ class ShipyardNeoBooter(ComputerBooter):
"""Pick the best profile for this session.
Resolution order:
- 1. User-specified profile (non-empty, non-default) → use as-is.
+ 1. User-specified profile (non-empty) → use as-is.
2. Query ``GET /v1/profiles`` and pick the profile with the most
capabilities, preferring profiles that include ``"browser"``.
3. Fall back to :attr:`DEFAULT_PROFILE`.
@@ -459,11 +463,9 @@ class ShipyardNeoBooter(ComputerBooter):
misconfigured token, and silently falling back would just delay the
real failure to ``create_sandbox``.
"""
- if self._profile and self._profile != self.DEFAULT_PROFILE:
- logger.info(
- "[Computer] profile_selected mode=user profile=%s",
- self._profile,
- )
+ # User explicitly set a profile → honour it.
+ if self._profile:
+ logger.info("[Computer] Using user-specified profile: %s", self._profile)
return self._profile
from shipyard_neo.errors import ForbiddenError, UnauthorizedError
@@ -509,11 +511,13 @@ class ShipyardNeoBooter(ComputerBooter):
if delete_sandbox and self._sandbox is not None:
try:
logger.info(
- "[Computer] Deleting Shipyard Neo sandbox: id=%s", sandbox_id
+ "[Computer] Deleting Shipyard Neo sandbox: id=%s",
+ sandbox_id,
)
await self._sandbox.delete()
logger.info(
- "[Computer] Shipyard Neo sandbox deleted: id=%s", sandbox_id
+ "[Computer] Shipyard Neo sandbox deleted: id=%s",
+ sandbox_id,
)
except Exception as e:
logger.warning(
diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py
index 72323163a..c28668f90 100644
--- a/astrbot/core/computer/computer_client.py
+++ b/astrbot/core/computer/computer_client.py
@@ -1,9 +1,12 @@
from __future__ import annotations
+import asyncio
import json
import os
import shutil
+import time
import uuid
+from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -27,6 +30,70 @@ local_booter: ComputerBooter | None = None
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
+@dataclass(slots=True)
+class _CUAIdleState:
+ expires_at: float
+ task: asyncio.Task
+
+
+cua_idle_state: dict[str, _CUAIdleState] = {}
+
+
+def _get_cua_idle_timeout(config: dict) -> float:
+ sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
+ value = sandbox_cfg.get("cua_idle_timeout", 0)
+ try:
+ timeout = float(value)
+ except (TypeError, ValueError):
+ return 0.0
+ return max(timeout, 0.0)
+
+
+def _clear_cua_idle_state(session_id: str) -> None:
+ state = cua_idle_state.pop(session_id, None)
+ if state is not None and not state.task.done():
+ state.task.cancel()
+
+
+def _schedule_cua_idle_cleanup(session_id: str, timeout: float) -> None:
+ _clear_cua_idle_state(session_id)
+ if timeout <= 0:
+ return
+ expires_at = time.monotonic() + timeout
+
+ async def _expire_when_idle() -> None:
+ try:
+ remaining = expires_at - time.monotonic()
+ if remaining > 0:
+ await asyncio.sleep(remaining)
+
+ state = cua_idle_state.get(session_id)
+ if state is None or state.expires_at != expires_at:
+ return
+
+ booter = session_booter.get(session_id)
+ if booter is not None:
+ try:
+ await booter.shutdown()
+ except Exception as shutdown_err:
+ logger.warning(
+ "[Computer] Failed to shutdown idle CUA sandbox for session %s: %s",
+ session_id,
+ shutdown_err,
+ )
+ finally:
+ session_booter.pop(session_id, None)
+ except asyncio.CancelledError:
+ raise
+ finally:
+ state = cua_idle_state.get(session_id)
+ if state is not None and state.expires_at == expires_at:
+ cua_idle_state.pop(session_id, None)
+
+ task = asyncio.create_task(_expire_when_idle())
+ cua_idle_state[session_id] = _CUAIdleState(expires_at=expires_at, task=task)
+
+
def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
skills: list[Path] = []
for entry in sorted(skills_root.iterdir()):
@@ -388,7 +455,7 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
"""
logger.info("[Computer] sandbox_sync phase=apply status=start")
apply_result = _normalize_shell_exec_result(
- await booter.shell.exec(_build_apply_sync_command())
+ await booter.shell.exec(_build_apply_sync_command()),
)
if not _shell_exec_succeeded(apply_result):
detail = _format_exec_error_detail(apply_result)
@@ -404,7 +471,7 @@ async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None:
"""Scan sandbox skills and return normalized payload for cache update."""
logger.info("[Computer] sandbox_sync phase=scan status=start")
scan_result = _normalize_shell_exec_result(
- await booter.shell.exec(_build_scan_command())
+ await booter.shell.exec(_build_scan_command()),
)
if not _shell_exec_succeeded(scan_result):
detail = _format_exec_error_detail(scan_result)
@@ -509,6 +576,7 @@ async def get_booter(
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
+ cua_idle_timeout = _get_cua_idle_timeout(config) if booter_type == "cua" else 0.0
if session_id in session_booter:
booter = session_booter[session_id]
@@ -529,6 +597,7 @@ async def get_booter(
session_id,
shutdown_err,
)
+ _clear_cua_idle_state(session_id)
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
@@ -578,7 +647,7 @@ async def get_booter(
cua_kwargs = build_cua_booter_kwargs(sandbox_cfg)
logger.info(
f"[Computer] CUA config: image={cua_kwargs['image']}, "
- f"os_type={cua_kwargs['os_type']}, ttl={cua_kwargs['ttl']}"
+ f"os_type={cua_kwargs['os_type']}, ttl={cua_kwargs['ttl']}",
)
client = CuaBooter(**cua_kwargs)
elif booter_type == "boxlite":
@@ -609,9 +678,12 @@ async def get_booter(
session_id,
shutdown_error,
)
+ _clear_cua_idle_state(session_id)
raise e
session_booter[session_id] = client
+ if booter_type == "cua":
+ _schedule_cua_idle_cleanup(session_id, cua_idle_timeout)
return session_booter[session_id]
diff --git a/astrbot/core/computer/olayer/__init__.py b/astrbot/core/computer/olayer/__init__.py
index 270ce2ffb..c153d92f9 100644
--- a/astrbot/core/computer/olayer/__init__.py
+++ b/astrbot/core/computer/olayer/__init__.py
@@ -5,11 +5,11 @@ from .python import PythonComponent
from .shell import ShellComponent
__all__ = [
+ "BrowserComponent",
"BrowserComponent",
"FileSystemComponent",
+ "FileSystemComponent",
+ "GUIComponent",
"PythonComponent",
"ShellComponent",
- "FileSystemComponent",
- "BrowserComponent",
- "GUIComponent",
]
diff --git a/astrbot/core/computer/olayer/gui.py b/astrbot/core/computer/olayer/gui.py
index cc23b9d7a..ad131f446 100644
--- a/astrbot/core/computer/olayer/gui.py
+++ b/astrbot/core/computer/olayer/gui.py
@@ -1,6 +1,4 @@
-"""
-GUI automation component.
-"""
+"""GUI automation component."""
from typing import Any, Protocol
diff --git a/astrbot/core/computer/shell_session.py b/astrbot/core/computer/shell_session.py
index 02326a66c..9d705f366 100644
--- a/astrbot/core/computer/shell_session.py
+++ b/astrbot/core/computer/shell_session.py
@@ -92,6 +92,7 @@ class PersistentShellSession:
-------
dict with keys ``stdout``, ``stderr``, ``exit_code`` and (when
background) ``background_task``.
+
"""
await self._ensure_running()
diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py
index 66b25565c..4ba6cdcb1 100644
--- a/astrbot/core/config/astrbot_config.py
+++ b/astrbot/core/config/astrbot_config.py
@@ -6,12 +6,16 @@ from typing import Any
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.auth_password import (
- normalize_dashboard_password_hash,
+ generate_dashboard_password,
+ hash_dashboard_password,
+ hash_legacy_dashboard_password,
+ validate_dashboard_password,
)
from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
+DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
logger = logging.getLogger("astrbot")
@@ -59,21 +63,64 @@ class AstrBotConfig(dict):
# Handle UTF-8 BOM if present
conf_str = conf_str.removeprefix("\ufeff")
conf = json.loads(conf_str) if conf_str.strip() else {}
-
+ dashboard_conf = conf.get("dashboard")
+ legacy_dashboard_password_change_required = bool(
+ isinstance(dashboard_conf, dict)
+ and dashboard_conf.get("password_change_required", False),
+ )
+ if legacy_dashboard_password_change_required:
+ object.__setattr__(
+ self,
+ "_dashboard_password_change_required_from_config",
+ True,
+ )
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
if (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
+ and not conf["dashboard"].get("pbkdf2_password")
and not conf["dashboard"].get("password")
+ ) or (
+ "dashboard" in conf
+ and isinstance(conf["dashboard"], dict)
+ and legacy_dashboard_password_change_required
+ and conf["dashboard"].get("pbkdf2_password")
):
- conf["dashboard"]["password"] = normalize_dashboard_password_hash("")
+ self._reset_generated_dashboard_password(conf)
has_new = True
self.update(conf)
if has_new:
self.save_config()
- self.update(conf)
+ def _reset_generated_dashboard_password(self, conf: dict) -> None:
+ generated_password = self._resolve_initial_dashboard_password()
+ conf["dashboard"]["pbkdf2_password"] = hash_dashboard_password(
+ generated_password,
+ )
+ conf["dashboard"]["password"] = hash_legacy_dashboard_password(
+ generated_password,
+ )
+ conf["dashboard"]["password_storage_upgraded"] = True
+ conf["dashboard"]["password_change_required"] = True
+ object.__setattr__(
+ self,
+ "_generated_dashboard_password",
+ generated_password,
+ )
+ object.__setattr__(
+ self,
+ "_generated_dashboard_password_change_required",
+ True,
+ )
+
+ @staticmethod
+ def _resolve_initial_dashboard_password() -> str:
+ env_password = os.environ.get(DASHBOARD_INITIAL_PASSWORD_ENV)
+ if env_password is None:
+ return generate_dashboard_password()
+ validate_dashboard_password(env_password)
+ return env_password
def _config_schema_to_default_config(self, schema: dict) -> dict:
"""将 Schema 转换成 Config"""
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index eb02f61c1..2e1c65f4b 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -9,7 +9,7 @@ from astrbot.builtin_stars.web_searcher.provider_constants import (
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-VERSION = "4.24.2"
+VERSION = "4.25.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -181,7 +181,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"shipyard_neo_ttl": 3600,
"cua_image": CUA_DEFAULT_CONFIG["image"],
"cua_os_type": CUA_DEFAULT_CONFIG["os_type"],
- "cua_ttl": CUA_DEFAULT_CONFIG["ttl"],
+ "cua_idle_timeout": CUA_DEFAULT_CONFIG["idle_timeout"],
"cua_telemetry_enabled": CUA_DEFAULT_CONFIG["telemetry_enabled"],
"cua_local": CUA_DEFAULT_CONFIG["local"],
"cua_api_key": CUA_DEFAULT_CONFIG["api_key"],
@@ -248,6 +248,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
"enable": True,
"username": "astrbot",
"password": "",
+ "pbkdf2_password": "",
+ "password_storage_upgraded": False,
+ "password_change_required": False,
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
@@ -318,7 +321,7 @@ CONFIG_METADATA_2: Any = {
"QQ 官方机器人(WebSocket)": {
"id": "default",
"type": "qq_official",
- "enable": False,
+ "enable": True,
"appid": "",
"secret": "",
"enable_group_c2c": True,
@@ -327,7 +330,7 @@ CONFIG_METADATA_2: Any = {
"QQ 官方机器人(Webhook)": {
"id": "default",
"type": "qq_official_webhook",
- "enable": False,
+ "enable": True,
"appid": "",
"secret": "",
"is_sandbox": False,
@@ -339,7 +342,7 @@ CONFIG_METADATA_2: Any = {
"OneBot v11": {
"id": "default",
"type": "aiocqhttp",
- "enable": False,
+ "enable": True,
"ws_reverse_host": "0.0.0.0",
"ws_reverse_port": 6199,
"ws_reverse_token": "",
@@ -347,7 +350,7 @@ CONFIG_METADATA_2: Any = {
"微信公众平台": {
"id": "weixin_official_account",
"type": "weixin_official_account",
- "enable": False,
+ "enable": True,
"appid": "",
"secret": "",
"token": "",
@@ -362,7 +365,7 @@ CONFIG_METADATA_2: Any = {
"企业微信(含微信客服)": {
"id": "wecom",
"type": "wecom",
- "enable": False,
+ "enable": True,
"corpid": "",
"secret": "",
"token": "",
@@ -399,7 +402,7 @@ CONFIG_METADATA_2: Any = {
"个人微信": {
"id": "weixin_personal",
"type": "weixin_oc",
- "enable": False,
+ "enable": True,
"weixin_oc_base_url": "https://ilinkai.weixin.qq.com",
"weixin_oc_bot_type": "3",
"weixin_oc_qr_poll_interval": 1,
@@ -409,8 +412,7 @@ CONFIG_METADATA_2: Any = {
"飞书(Lark)": {
"id": "lark",
"type": "lark",
- "enable": False,
- "lark_bot_name": "",
+ "enable": True,
"app_id": "",
"app_secret": "",
"domain": "https://open.feishu.cn",
@@ -422,7 +424,7 @@ CONFIG_METADATA_2: Any = {
"钉钉(DingTalk)": {
"id": "dingtalk",
"type": "dingtalk",
- "enable": False,
+ "enable": True,
"client_id": "",
"client_secret": "",
"card_template_id": "",
@@ -430,7 +432,7 @@ CONFIG_METADATA_2: Any = {
"Telegram": {
"id": "telegram",
"type": "telegram",
- "enable": False,
+ "enable": True,
"telegram_token": "your_bot_token",
"start_message": "Hello, I'm AstrBot!",
"telegram_api_base_url": "https://api.telegram.org/bot",
@@ -443,7 +445,7 @@ CONFIG_METADATA_2: Any = {
"Discord": {
"id": "discord",
"type": "discord",
- "enable": False,
+ "enable": True,
"discord_token": "",
"discord_proxy": "",
"discord_command_register": True,
@@ -452,7 +454,7 @@ CONFIG_METADATA_2: Any = {
"Misskey": {
"id": "misskey",
"type": "misskey",
- "enable": False,
+ "enable": True,
"misskey_instance_url": "https://misskey.example",
"misskey_token": "",
"misskey_default_visibility": "public",
@@ -470,7 +472,7 @@ CONFIG_METADATA_2: Any = {
"Slack": {
"id": "slack",
"type": "slack",
- "enable": False,
+ "enable": True,
"bot_token": "",
"app_token": "",
"signing_secret": "",
@@ -484,7 +486,7 @@ CONFIG_METADATA_2: Any = {
"Line": {
"id": "line",
"type": "line",
- "enable": False,
+ "enable": True,
"channel_access_token": "",
"channel_secret": "",
"unified_webhook_mode": True,
@@ -493,7 +495,7 @@ CONFIG_METADATA_2: Any = {
"Satori": {
"id": "satori",
"type": "satori",
- "enable": False,
+ "enable": True,
"satori_api_base_url": "http://localhost:5140/satori/v1",
"satori_endpoint": "ws://localhost:5140/satori/v1/events",
"satori_token": "",
@@ -504,7 +506,7 @@ CONFIG_METADATA_2: Any = {
"kook": {
"id": "kook",
"type": "kook",
- "enable": False,
+ "enable": True,
"kook_bot_token": "",
"kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60,
@@ -514,6 +516,14 @@ CONFIG_METADATA_2: Any = {
"kook_max_heartbeat_failures": 3,
"kook_max_consecutive_failures": 5,
},
+ "Mattermost": {
+ "id": "mattermost",
+ "type": "mattermost",
+ "enable": True,
+ "mattermost_url": "https://chat.example.com",
+ "mattermost_bot_token": "",
+ "mattermost_reconnect_delay": 5.0,
+ },
# "WebChat": {
# "id": "webchat",
# "type": "webchat",
@@ -865,11 +875,6 @@ CONFIG_METADATA_2: Any = {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
- "lark_bot_name": {
- "description": "飞书机器人的名字",
- "type": "string",
- "hint": "请务必填写正确,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
- },
"discord_token": {
"description": "Discord Bot Token",
"type": "string",
@@ -1770,6 +1775,34 @@ CONFIG_METADATA_2: Any = {
"timeout": 20,
"proxy": "",
},
+ "NVIDIA Embedding": {
+ "id": "nvidia_embedding",
+ "type": "nvidia_embedding",
+ "provider": "nvidia",
+ "provider_type": "embedding",
+ "hint": "provider_group.provider.nvidia_embedding.hint",
+ "enable": True,
+ "embedding_api_key": "",
+ "embedding_api_base": "https://integrate.api.nvidia.com/v1",
+ "embedding_model": "nvidia/llama-nemotron-embed-1b-v2",
+ "input_type": "passage",
+ "embedding_dimensions": 1024,
+ "timeout": 20,
+ "proxy": "",
+ },
+ "Ollama Embedding": {
+ "id": "ollama_embedding",
+ "type": "ollama_embedding",
+ "provider": "ollama",
+ "provider_type": "embedding",
+ "hint": "provider_group.provider.ollama_embedding.hint",
+ "enable": True,
+ "embedding_api_base": "http://localhost:11434",
+ "embedding_model": "nomic-embed-text",
+ "embedding_dimensions": 768,
+ "timeout": 60,
+ "proxy": "",
+ },
"vLLM Rerank": {
"id": "vllm_rerank",
"type": "vllm_rerank",
@@ -3276,7 +3309,7 @@ CONFIG_METADATA_3 = {
"provider_settings.sandbox.shipyard_neo_profile": {
"description": "Shipyard Neo Profile",
"type": "string",
- "hint": "Shipyard Neo 沙箱 profile,如 python-default。",
+ "hint": "Shipyard Neo 沙箱 profile,如 python-default。留空时自动选择能力更完整的 profile。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard_neo",
@@ -3311,10 +3344,10 @@ CONFIG_METADATA_3 = {
"provider_settings.sandbox.booter": "cua",
},
},
- "provider_settings.sandbox.cua_ttl": {
- "description": "CUA Sandbox TTL",
+ "provider_settings.sandbox.cua_idle_timeout": {
+ "description": "CUA Idle Timeout",
"type": "int",
- "hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。",
+ "hint": "Idle timeout for CUA sandbox sessions in seconds. When greater than 0, AstrBot proactively shuts down an idle CUA sandbox after that amount of inactivity; 0 disables it.",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py
index 275ce4355..fea32bc55 100644
--- a/astrbot/core/db/sqlite.py
+++ b/astrbot/core/db/sqlite.py
@@ -76,12 +76,12 @@ class SQLiteDatabase(BaseDatabase):
if "folder_id" not in columns:
await conn.execute(
text(
- "ALTER TABLE personas ADD COLUMN folder_id VARCHAR(36) DEFAULT NULL"
- )
+ "ALTER TABLE personas ADD COLUMN folder_id VARCHAR(36) DEFAULT NULL",
+ ),
)
if "sort_order" not in columns:
await conn.execute(
- text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0")
+ text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0"),
)
async def _ensure_persona_skills_column(self, conn) -> None:
@@ -103,7 +103,7 @@ class SQLiteDatabase(BaseDatabase):
if "custom_error_message" not in columns:
await conn.execute(
- text("ALTER TABLE personas ADD COLUMN custom_error_message TEXT")
+ text("ALTER TABLE personas ADD COLUMN custom_error_message TEXT"),
)
async def _ensure_platform_message_history_checkpoint_column(self, conn) -> None:
@@ -115,15 +115,15 @@ class SQLiteDatabase(BaseDatabase):
await conn.execute(
text(
"ALTER TABLE platform_message_history "
- "ADD COLUMN llm_checkpoint_id VARCHAR DEFAULT NULL"
- )
+ "ADD COLUMN llm_checkpoint_id VARCHAR DEFAULT NULL",
+ ),
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS "
"ix_platform_message_history_llm_checkpoint_id "
- "ON platform_message_history (llm_checkpoint_id)"
- )
+ "ON platform_message_history (llm_checkpoint_id)",
+ ),
)
# ====
@@ -409,7 +409,7 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin():
await session.execute(
delete(ConversationV2).where(
- col(ConversationV2.user_id) == user_id
+ col(ConversationV2.user_id) == user_id,
),
)
@@ -567,7 +567,7 @@ class SQLiteDatabase(BaseDatabase):
await session.execute(
update(PlatformMessageHistory)
.where(PlatformMessageHistory.id == message_id)
- .values(**values)
+ .values(**values),
)
async def delete_platform_message_history_by_id(self, message_id: int) -> None:
@@ -577,8 +577,8 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin():
await session.execute(
delete(PlatformMessageHistory).where(
- PlatformMessageHistory.id == message_id
- )
+ PlatformMessageHistory.id == message_id,
+ ),
)
async def delete_platform_message_offset(
@@ -624,13 +624,14 @@ class SQLiteDatabase(BaseDatabase):
return result.scalars().all()
async def get_platform_message_history_by_id(
- self, message_id: int
+ self,
+ message_id: int,
) -> PlatformMessageHistory | None:
"""Get a platform message history record by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformMessageHistory).where(
- PlatformMessageHistory.id == message_id
+ PlatformMessageHistory.id == message_id,
)
result = await session.execute(query)
return result.scalar_one_or_none()
@@ -667,7 +668,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
- select(WebChatThread).where(WebChatThread.thread_id == thread_id)
+ select(WebChatThread).where(WebChatThread.thread_id == thread_id),
)
return result.scalar_one_or_none()
@@ -680,7 +681,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
query = select(WebChatThread).where(
- WebChatThread.parent_session_id == parent_session_id
+ WebChatThread.parent_session_id == parent_session_id,
)
if creator is not None:
query = query.where(WebChatThread.creator == creator)
@@ -714,7 +715,7 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
await session.execute(
- delete(WebChatThread).where(WebChatThread.thread_id == thread_id)
+ delete(WebChatThread).where(WebChatThread.thread_id == thread_id),
)
async def delete_webchat_threads_by_parent_session(
@@ -731,8 +732,8 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin():
await session.execute(
delete(WebChatThread).where(
- col(WebChatThread.thread_id).in_(thread_ids)
- )
+ col(WebChatThread.thread_id).in_(thread_ids),
+ ),
)
return thread_ids
@@ -750,7 +751,7 @@ class SQLiteDatabase(BaseDatabase):
select(WebChatThread.thread_id).where(
WebChatThread.parent_session_id == parent_session_id,
col(WebChatThread.parent_message_id).in_(parent_message_ids),
- )
+ ),
)
thread_ids = list(result.scalars().all())
if not thread_ids:
@@ -760,8 +761,8 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin():
await session.execute(
delete(WebChatThread).where(
- col(WebChatThread.thread_id).in_(thread_ids)
- )
+ col(WebChatThread.thread_id).in_(thread_ids),
+ ),
)
return thread_ids
@@ -793,7 +794,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
query = select(Attachment).where(
- col(Attachment.attachment_id).in_(attachment_ids)
+ col(Attachment.attachment_id).in_(attachment_ids),
)
result = await session.execute(query)
return list(result.scalars().all())
@@ -807,9 +808,9 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
query = delete(Attachment).where(
- col(Attachment.attachment_id) == attachment_id
+ col(Attachment.attachment_id) == attachment_id,
)
- result = T.cast(CursorResult, await session.execute(query))
+ result = T.cast("CursorResult", await session.execute(query))
return result.rowcount > 0
async def delete_attachments(self, attachment_ids: list[str]) -> int:
@@ -823,9 +824,9 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
query = delete(Attachment).where(
- col(Attachment.attachment_id).in_(attachment_ids)
+ col(Attachment.attachment_id).in_(attachment_ids),
)
- result = T.cast(CursorResult, await session.execute(query))
+ result = T.cast("CursorResult", await session.execute(query))
return result.rowcount
async def create_api_key(
@@ -859,7 +860,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
- select(ApiKey).order_by(desc(ApiKey.created_at))
+ select(ApiKey).order_by(desc(ApiKey.created_at)),
)
return list(result.scalars().all())
@@ -868,7 +869,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
- select(ApiKey).where(ApiKey.key_id == key_id)
+ select(ApiKey).where(ApiKey.key_id == key_id),
)
return result.scalar_one_or_none()
@@ -906,7 +907,7 @@ class SQLiteDatabase(BaseDatabase):
.where(col(ApiKey.key_id) == key_id)
.values(revoked_at=datetime.now(timezone.utc))
)
- result = T.cast(CursorResult, await session.execute(query))
+ result = T.cast("CursorResult", await session.execute(query))
return result.rowcount > 0
async def delete_api_key(self, key_id: str) -> bool:
@@ -915,9 +916,9 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
result = T.cast(
- CursorResult,
+ "CursorResult",
await session.execute(
- delete(ApiKey).where(col(ApiKey.key_id) == key_id)
+ delete(ApiKey).where(col(ApiKey.key_id) == key_id),
),
)
return result.rowcount > 0
@@ -1043,13 +1044,15 @@ class SQLiteDatabase(BaseDatabase):
return result.scalar_one_or_none()
async def get_persona_folders(
- self, parent_id: str | None = None
+ self,
+ parent_id: str | None = None,
) -> list[PersonaFolder]:
"""Get all persona folders, optionally filtered by parent_id.
Args:
parent_id: If None, returns root folders only. If specified, returns
children of that folder.
+
"""
async with self.get_db() as session:
session: AsyncSession
@@ -1074,7 +1077,8 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
query = select(PersonaFolder).order_by(
- col(PersonaFolder.sort_order), col(PersonaFolder.name)
+ col(PersonaFolder.sort_order),
+ col(PersonaFolder.name),
)
result = await session.execute(query)
return list(result.scalars().all())
@@ -1092,7 +1096,7 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
query = update(PersonaFolder).where(
- col(PersonaFolder.folder_id) == folder_id
+ col(PersonaFolder.folder_id) == folder_id,
)
values: dict[str, T.Any] = {}
if name is not None:
@@ -1122,17 +1126,19 @@ class SQLiteDatabase(BaseDatabase):
await session.execute(
update(Persona)
.where(col(Persona.folder_id) == folder_id)
- .values(folder_id=None)
+ .values(folder_id=None),
)
# Delete the folder
await session.execute(
delete(PersonaFolder).where(
- col(PersonaFolder.folder_id) == folder_id
+ col(PersonaFolder.folder_id) == folder_id,
),
)
async def move_persona_to_folder(
- self, persona_id: str, folder_id: str | None
+ self,
+ persona_id: str,
+ folder_id: str | None,
) -> Persona | None:
"""Move a persona to a folder (or root if folder_id is None)."""
async with self.get_db() as session:
@@ -1141,17 +1147,19 @@ class SQLiteDatabase(BaseDatabase):
await session.execute(
update(Persona)
.where(col(Persona.persona_id) == persona_id)
- .values(folder_id=folder_id)
+ .values(folder_id=folder_id),
)
return await self.get_persona_by_id(persona_id)
async def get_personas_by_folder(
- self, folder_id: str | None = None
+ self,
+ folder_id: str | None = None,
) -> list[Persona]:
"""Get all personas in a specific folder.
Args:
folder_id: If None, returns personas in root directory.
+
"""
async with self.get_db() as session:
session: AsyncSession
@@ -1181,6 +1189,7 @@ class SQLiteDatabase(BaseDatabase):
- id: The persona_id or folder_id
- type: Either "persona" or "folder"
- sort_order: The new sort_order value
+
"""
if not items:
return
@@ -1200,13 +1209,13 @@ class SQLiteDatabase(BaseDatabase):
await session.execute(
update(Persona)
.where(col(Persona.persona_id) == item_id)
- .values(sort_order=sort_order)
+ .values(sort_order=sort_order),
)
elif item_type == "folder":
await session.execute(
update(PersonaFolder)
.where(col(PersonaFolder.folder_id) == item_id)
- .values(sort_order=sort_order)
+ .values(sort_order=sort_order),
)
async def insert_preference_or_update(self, scope, scope_id, key, value):
@@ -1649,7 +1658,8 @@ class SQLiteDatabase(BaseDatabase):
return new_session
async def get_platform_session_by_id(
- self, session_id: str
+ self,
+ session_id: str,
) -> PlatformSession | None:
"""Get a Platform session by its ID."""
async with self.get_db() as session:
@@ -1661,7 +1671,8 @@ class SQLiteDatabase(BaseDatabase):
return result.scalar_one_or_none()
async def get_platform_sessions_by_ids(
- self, session_ids: list[str]
+ self,
+ session_ids: list[str],
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
if not session_ids:
@@ -1670,7 +1681,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
- col(PlatformSession.session_id).in_(session_ids)
+ col(PlatformSession.session_id).in_(session_ids),
)
result = await session.execute(query)
return list(result.scalars().all())
@@ -1769,7 +1780,7 @@ class SQLiteDatabase(BaseDatabase):
)
total_result = await session.execute(
- select(func.count()).select_from(base_query.subquery())
+ select(func.count()).select_from(base_query.subquery()),
)
total = int(total_result.scalar_one() or 0)
@@ -1973,7 +1984,9 @@ class SQLiteDatabase(BaseDatabase):
return list(result.scalars().all())
async def get_project_by_session(
- self, session_id: str, creator: str
+ self,
+ session_id: str,
+ creator: str,
) -> ChatUIProject | None:
"""Get the project that a session belongs to."""
async with self.get_db() as session:
@@ -2080,7 +2093,7 @@ class SQLiteDatabase(BaseDatabase):
)
await session.execute(stmt)
result = await session.execute(
- select(CronJob).where(col(CronJob.job_id) == job_id)
+ select(CronJob).where(col(CronJob.job_id) == job_id),
)
return result.scalar_one_or_none()
@@ -2089,14 +2102,14 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
await session.execute(
- delete(CronJob).where(col(CronJob.job_id) == job_id)
+ delete(CronJob).where(col(CronJob.job_id) == job_id),
)
async def get_cron_job(self, job_id: str) -> CronJob | None:
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
- select(CronJob).where(col(CronJob.job_id) == job_id)
+ select(CronJob).where(col(CronJob.job_id) == job_id),
)
return result.scalar_one_or_none()
@@ -2130,7 +2143,7 @@ class SQLiteDatabase(BaseDatabase):
.where(
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
- )
+ ),
)
total = count_result.scalar()
return records, total
@@ -2149,7 +2162,7 @@ class SQLiteDatabase(BaseDatabase):
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
col(PlatformMessageHistory.created_at) < before,
- )
+ ),
)
return result.rowcount
@@ -2167,7 +2180,7 @@ class SQLiteDatabase(BaseDatabase):
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
col(PlatformMessageHistory.created_at) > after,
- )
+ ),
)
return result.rowcount
@@ -2183,7 +2196,7 @@ class SQLiteDatabase(BaseDatabase):
delete(PlatformMessageHistory).where(
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
- )
+ ),
)
return result.rowcount
@@ -2200,7 +2213,7 @@ class SQLiteDatabase(BaseDatabase):
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
col(PlatformMessageHistory.idempotency_key) == idempotency_key,
- )
+ ),
)
return result.scalar_one_or_none()
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index 6cc8c3884..64df07dfc 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -86,19 +86,23 @@ class InternalAgentSubStage(Stage):
self.file_extract_enabled: bool = file_extract_conf.get("enable", False)
self.file_extract_prov: str = file_extract_conf.get("provider", "moonshotai")
self.file_extract_msh_api_key: str = file_extract_conf.get(
- "moonshotai_api_key", ""
+ "moonshotai_api_key",
+ "",
)
# 上下文管理相关
self.context_limit_reached_strategy: str = settings.get(
- "context_limit_reached_strategy", "truncate_by_turns"
+ "context_limit_reached_strategy",
+ "truncate_by_turns",
)
self.llm_compress_instruction: str = settings.get(
- "llm_compress_instruction", ""
+ "llm_compress_instruction",
+ "",
)
self.llm_compress_keep_recent: int = settings.get("llm_compress_keep_recent", 4)
self.llm_compress_provider_id: str = settings.get(
- "llm_compress_provider_id", ""
+ "llm_compress_provider_id",
+ "",
)
self.max_context_length = settings["max_context_length"] # int
self.dequeue_context_length: int = min(
@@ -108,12 +112,14 @@ class InternalAgentSubStage(Stage):
if self.dequeue_context_length <= 0:
self.dequeue_context_length = 1
self.fallback_max_context_tokens: int = settings.get(
- "fallback_max_context_tokens", 128000
+ "fallback_max_context_tokens",
+ 128000,
)
self.llm_safety_mode = settings.get("llm_safety_mode", True)
self.safety_mode_strategy = settings.get(
- "safety_mode_strategy", "system_prompt"
+ "safety_mode_strategy",
+ "system_prompt",
)
self.computer_use_runtime = settings.get("computer_use_runtime")
@@ -271,13 +277,13 @@ class InternalAgentSubStage(Stage):
# 获取 TTS Provider
tts_provider = (
self.ctx.plugin_manager.context.get_using_tts_provider(
- event.unified_msg_origin
+ event.unified_msg_origin,
)
)
if not tts_provider:
logger.warning(
- "[Live Mode] TTS Provider 未配置,将使用普通流式模式"
+ "[Live Mode] TTS Provider 未配置,将使用普通流式模式",
)
# 使用 run_live_agent,总是使用流式响应
@@ -372,7 +378,7 @@ class InternalAgentSubStage(Stage):
req,
agent_runner,
final_resp,
- )
+ ),
)
# 检查事件是否被停止,如果被停止则不保存历史记录
@@ -400,7 +406,7 @@ class InternalAgentSubStage(Stage):
except Exception as e:
logger.error(f"Error occurred while processing agent: {e}", exc_info=True)
custom_error_message = extract_persona_custom_error_message_from_event(
- event
+ event,
)
error_text = custom_error_message or (
f"Error occurred while processing agent request: {e}"
@@ -468,7 +474,7 @@ class InternalAgentSubStage(Stage):
message_to_save.append(
CheckpointMessageSegment(
content=CheckpointData(id=checkpoint_id),
- ).model_dump()
+ ).model_dump(),
)
# if user_aborted:
diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py
index 4067f8869..8e47e2883 100644
--- a/astrbot/core/pipeline/result_decorate/stage.py
+++ b/astrbot/core/pipeline/result_decorate/stage.py
@@ -294,7 +294,8 @@ class ResultDecorateStage(Stage):
)
else:
result.chain.insert(
- 0, Plain(f"🤔 思考: {reasoning_content}\n\n────\n")
+ 0,
+ Plain(f"🤔 思考: {reasoning_content}\n\n────\n"),
)
if should_tts and tts_provider:
diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py
index d23b46524..b90cd4fe1 100644
--- a/astrbot/core/platform/astr_message_event.py
+++ b/astrbot/core/platform/astr_message_event.py
@@ -64,7 +64,7 @@ class AstrMessageEvent(abc.ABC):
except (ValueError, TypeError, AttributeError):
logger.warning(
f"Failed to convert message type {message_obj.type!r} to MessageType. "
- f"Falling back to FRIEND_MESSAGE."
+ f"Falling back to FRIEND_MESSAGE.",
)
message_type = MessageType.FRIEND_MESSAGE
self.session = MessageSession(
diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py
index 3ee113d0d..a6b7103c9 100644
--- a/astrbot/core/platform/platform.py
+++ b/astrbot/core/platform/platform.py
@@ -141,7 +141,7 @@ class Platform(abc.ABC):
异步方法。
"""
asyncio.create_task(
- Metric.upload(msg_event_tick=1, adapter_name=self.meta().name)
+ Metric.upload(msg_event_tick=1, adapter_name=self.meta().name),
)
def commit_event(self, event: AstrMessageEvent) -> None:
diff --git a/astrbot/core/platform/sources/dingtalk/app_registration.py b/astrbot/core/platform/sources/dingtalk/app_registration.py
new file mode 100644
index 000000000..c177d30ae
--- /dev/null
+++ b/astrbot/core/platform/sources/dingtalk/app_registration.py
@@ -0,0 +1,146 @@
+import os
+from dataclasses import dataclass
+from typing import Any
+
+import aiohttp
+
+DEFAULT_DINGTALK_REGISTRATION_BASE_URL = "https://oapi.dingtalk.com"
+DEFAULT_DINGTALK_REGISTRATION_SOURCE = "DING_DWS_CLAW"
+
+
+@dataclass
+class DingtalkAppRegistration:
+ device_code: str
+ user_code: str
+ verification_uri: str
+ verification_uri_complete: str
+ expires_in: int
+ interval: int
+
+
+def dingtalk_registration_base_url() -> str:
+ return (
+ os.getenv("DINGTALK_REGISTRATION_BASE_URL", "").strip()
+ or DEFAULT_DINGTALK_REGISTRATION_BASE_URL
+ ).rstrip("/")
+
+
+def dingtalk_registration_source() -> str:
+ return (
+ os.getenv("DINGTALK_REGISTRATION_SOURCE", "").strip()
+ or DEFAULT_DINGTALK_REGISTRATION_SOURCE
+ )
+
+
+def _string_field(data: dict[str, Any], key: str) -> str:
+ value = data.get(key)
+ if isinstance(value, str):
+ return value.strip()
+ return ""
+
+
+def _int_field(data: dict[str, Any], key: str, default: int) -> int:
+ value = data.get(key)
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return int(value)
+ return default
+
+
+async def _post_registration(
+ path: str,
+ payload: dict[str, str],
+) -> tuple[int, dict[str, Any]]:
+ timeout = aiohttp.ClientTimeout(total=15)
+ async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
+ async with session.post(
+ f"{dingtalk_registration_base_url()}{path}",
+ json=payload,
+ ) as response:
+ status = response.status
+ data = await response.json(content_type=None)
+ if not isinstance(data, dict):
+ raise RuntimeError("DingTalk registration response format is invalid")
+ return status, data
+
+
+def _raise_dingtalk_registration_error(
+ status: int,
+ raw: dict[str, Any],
+ action: str,
+) -> None:
+ errcode = raw.get("errcode", 0)
+ if status < 400 and int(errcode or 0) == 0:
+ return
+ errmsg = _string_field(raw, "errmsg") or "unknown error"
+ raise RuntimeError(f"[{action}] {errmsg} (errcode={errcode})")
+
+
+async def request_dingtalk_app_registration() -> DingtalkAppRegistration:
+ status, init_raw = await _post_registration(
+ "/app/registration/init",
+ {"source": dingtalk_registration_source()},
+ )
+ _raise_dingtalk_registration_error(status, init_raw, "init")
+ nonce = _string_field(init_raw, "nonce")
+ if not nonce:
+ raise RuntimeError("[init] missing nonce")
+
+ status, begin_raw = await _post_registration(
+ "/app/registration/begin",
+ {"nonce": nonce},
+ )
+ _raise_dingtalk_registration_error(status, begin_raw, "begin")
+
+ device_code = _string_field(begin_raw, "device_code")
+ verification_uri_complete = _string_field(begin_raw, "verification_uri_complete")
+ if not device_code:
+ raise RuntimeError("[begin] missing device_code")
+ if not verification_uri_complete:
+ raise RuntimeError("[begin] missing verification_uri_complete")
+
+ return DingtalkAppRegistration(
+ device_code=device_code,
+ user_code=_string_field(begin_raw, "user_code"),
+ verification_uri=_string_field(begin_raw, "verification_uri"),
+ verification_uri_complete=verification_uri_complete,
+ expires_in=max(_int_field(begin_raw, "expires_in", 7200), 60),
+ interval=max(_int_field(begin_raw, "interval", 3), 1),
+ )
+
+
+async def poll_dingtalk_app_registration_once(device_code: str) -> dict[str, Any]:
+ status, raw = await _post_registration(
+ "/app/registration/poll",
+ {"device_code": device_code},
+ )
+ _raise_dingtalk_registration_error(status, raw, "poll")
+ return dingtalk_registration_poll_result(raw)
+
+
+def dingtalk_registration_poll_result(raw: dict[str, Any]) -> dict[str, Any]:
+ status_raw = _string_field(raw, "status").upper()
+ if status_raw == "WAITING":
+ return {"status": "pending"}
+ if status_raw == "SUCCESS":
+ client_id = _string_field(raw, "client_id")
+ client_secret = _string_field(raw, "client_secret")
+ if not client_id or not client_secret:
+ return {"status": "error", "message": "扫码成功但未获取到钉钉应用凭证"}
+ return {
+ "status": "created",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ }
+ if status_raw == "FAIL":
+ return {
+ "status": "error",
+ "message": _string_field(raw, "fail_reason") or "钉钉扫码创建失败",
+ }
+ if status_raw == "EXPIRED":
+ return {"status": "expired", "message": "钉钉扫码已过期,请重新创建"}
+ return {
+ "status": "error",
+ "message": f"钉钉扫码创建返回未知状态: {status_raw or 'UNKNOWN'}",
+ }
diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
index b1a8156a4..92189781c 100644
--- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
+++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
@@ -48,7 +48,9 @@ class MyEventHandler(dingtalk_stream.EventHandler):
@register_platform_adapter(
- "dingtalk", "钉钉机器人官方 API 适配器", support_streaming_message=True
+ "dingtalk",
+ "钉钉机器人官方 API 适配器",
+ support_streaming_message=True,
)
class DingtalkPlatformAdapter(Platform):
def __init__(
@@ -141,7 +143,7 @@ class DingtalkPlatformAdapter(Platform):
return PlatformMetadata(
name="dingtalk",
description="钉钉机器人官方 API 适配器",
- id=cast(str, self.config.get("id")),
+ id=cast("str", self.config.get("id")),
support_streaming_message=True,
support_proactive_message=True,
)
@@ -153,7 +155,7 @@ class DingtalkPlatformAdapter(Platform):
abm = AstrBotMessage()
abm.message = []
abm.message_str = ""
- abm.timestamp = int(cast(int, message.create_at) / 1000)
+ abm.timestamp = int(cast("int", message.create_at) / 1000)
abm.type = (
MessageType.GROUP_MESSAGE
if message.conversation_type == "2"
@@ -164,7 +166,7 @@ class DingtalkPlatformAdapter(Platform):
nickname=message.sender_nick,
)
abm.self_id = self._id_to_sid(message.chatbot_user_id)
- abm.message_id = cast(str, message.message_id)
+ abm.message_id = cast("str", message.message_id)
abm.raw_message = message
if abm.type == MessageType.GROUP_MESSAGE:
@@ -178,9 +180,9 @@ class DingtalkPlatformAdapter(Platform):
else:
abm.session_id = abm.sender.user_id
- message_type: str = cast(str, message.message_type)
- robot_code = cast(str, message.robot_code or "")
- raw_content = cast(dict, message.extensions.get("content") or {})
+ message_type: str = cast("str", message.message_type)
+ robot_code = cast("str", message.robot_code or "")
+ raw_content = cast("dict", message.extensions.get("content") or {})
if not isinstance(raw_content, dict):
raw_content = {}
match message_type:
@@ -193,11 +195,12 @@ class DingtalkPlatformAdapter(Platform):
await self._remember_sender_binding(message, abm)
return abm
image_content = cast(
- dingtalk_stream.ImageContent | None,
+ "dingtalk_stream.ImageContent | None",
message.image_content,
)
download_code = cast(
- str, (image_content.download_code if image_content else "") or ""
+ "str",
+ (image_content.download_code if image_content else "") or "",
)
if not download_code:
logger.warning("钉钉图片消息缺少 downloadCode,已跳过")
@@ -213,26 +216,27 @@ class DingtalkPlatformAdapter(Platform):
logger.warning("钉钉图片消息下载失败,无法解析为图片")
case "richText":
rtc: dingtalk_stream.RichTextContent = cast(
- dingtalk_stream.RichTextContent, message.rich_text_content
+ "dingtalk_stream.RichTextContent",
+ message.rich_text_content,
)
- contents: list[dict] = cast(list[dict], rtc.rich_text_list)
+ contents: list[dict] = cast("list[dict]", rtc.rich_text_list)
plain_parts: list[str] = []
for content in contents:
if "text" in content:
- plain_text = cast(str, content.get("text") or "")
+ plain_text = cast("str", content.get("text") or "")
if plain_text:
plain_parts.append(plain_text)
abm.message.append(Plain(plain_text))
elif "type" in content and content["type"] == "picture":
- download_code = cast(str, content.get("downloadCode") or "")
+ download_code = cast("str", content.get("downloadCode") or "")
if not download_code:
logger.warning(
- "钉钉富文本图片消息缺少 downloadCode,已跳过"
+ "钉钉富文本图片消息缺少 downloadCode,已跳过",
)
continue
if not robot_code:
logger.error(
- "钉钉富文本图片消息解析失败: 回调中缺少 robotCode"
+ "钉钉富文本图片消息解析失败: 回调中缺少 robotCode",
)
continue
f_path = await self.download_ding_file(
@@ -244,13 +248,13 @@ class DingtalkPlatformAdapter(Platform):
abm.message.append(Image.fromFileSystem(f_path))
abm.message_str = "".join(plain_parts).strip()
case "audio" | "voice":
- download_code = cast(str, raw_content.get("downloadCode") or "")
+ download_code = cast("str", raw_content.get("downloadCode") or "")
if not download_code:
logger.warning("钉钉语音消息缺少 downloadCode,已跳过")
elif not robot_code:
logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode")
else:
- voice_ext = cast(str, raw_content.get("fileExtension") or "")
+ voice_ext = cast("str", raw_content.get("fileExtension") or "")
if not voice_ext:
voice_ext = "amr"
voice_ext = voice_ext.lstrip(".")
@@ -262,16 +266,16 @@ class DingtalkPlatformAdapter(Platform):
if f_path:
abm.message.append(Record.fromFileSystem(f_path))
case "file":
- download_code = cast(str, raw_content.get("downloadCode") or "")
+ download_code = cast("str", raw_content.get("downloadCode") or "")
if not download_code:
logger.warning("钉钉文件消息缺少 downloadCode,已跳过")
elif not robot_code:
logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode")
else:
- file_name = cast(str, raw_content.get("fileName") or "")
+ file_name = cast("str", raw_content.get("fileName") or "")
file_ext = Path(file_name).suffix.lstrip(".") if file_name else ""
if not file_ext:
- file_ext = cast(str, raw_content.get("fileExtension") or "")
+ file_ext = cast("str", raw_content.get("fileExtension") or "")
if not file_ext:
file_ext = "file"
f_path = await self.download_ding_file(
@@ -295,14 +299,14 @@ class DingtalkPlatformAdapter(Platform):
try:
if abm.type == MessageType.FRIEND_MESSAGE:
sender_id = abm.sender.user_id
- sender_staff_id = cast(str, message.sender_staff_id or "")
+ sender_staff_id = cast("str", message.sender_staff_id or "")
if sender_staff_id:
umo = str(
MessageSesion(
platform_name=self.meta().id,
message_type=abm.type,
session_id=sender_id,
- )
+ ),
)
await sp.put_async(
"global",
@@ -353,7 +357,7 @@ class DingtalkPlatformAdapter(Platform):
return ""
resp_data = await resp.json()
download_url = cast(
- str,
+ "str",
(
resp_data.get("downloadUrl")
or resp_data.get("data", {}).get("downloadUrl")
@@ -389,7 +393,7 @@ class DingtalkPlatformAdapter(Platform):
)
return ""
data = await resp.json()
- return cast(str, data.get("data", {}).get("accessToken", ""))
+ return cast("str", data.get("data", {}).get("accessToken", ""))
async def _get_sender_staff_id(self, session: MessageSesion) -> str:
try:
@@ -399,7 +403,7 @@ class DingtalkPlatformAdapter(Platform):
"dingtalk_staffid",
"",
)
- return cast(str, staff_id or "")
+ return cast("str", staff_id or "")
except Exception as e:
logger.warning(f"读取钉钉 staff_id 映射失败: {e}")
return ""
@@ -426,16 +430,18 @@ class DingtalkPlatformAdapter(Platform):
"Content-Type": "application/json",
"x-acs-dingtalk-access-token": access_token,
}
- async with aiohttp.ClientSession() as session:
- async with session.post(
+ async with (
+ aiohttp.ClientSession() as session,
+ session.post(
"https://api.dingtalk.com/v1.0/robot/groupMessages/send",
headers=headers,
json=payload,
- ) as resp:
- if resp.status != 200:
- logger.error(
- f"钉钉群消息发送失败: {resp.status}, {await resp.text()}",
- )
+ ) as resp,
+ ):
+ if resp.status != 200:
+ logger.error(
+ f"钉钉群消息发送失败: {resp.status}, {await resp.text()}",
+ )
async def _send_private_message(
self,
@@ -459,16 +465,18 @@ class DingtalkPlatformAdapter(Platform):
"Content-Type": "application/json",
"x-acs-dingtalk-access-token": access_token,
}
- async with aiohttp.ClientSession() as session:
- async with session.post(
+ async with (
+ aiohttp.ClientSession() as session,
+ session.post(
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
headers=headers,
json=payload,
- ) as resp:
- if resp.status != 200:
- logger.error(
- f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}",
- )
+ ) as resp,
+ ):
+ if resp.status != 200:
+ logger.error(
+ f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}",
+ )
def _safe_remove_file(self, file_path: str | None) -> None:
if not file_path:
@@ -515,14 +523,14 @@ class DingtalkPlatformAdapter(Platform):
) as resp:
if resp.status != 200:
logger.error(
- f"钉钉媒体上传失败: {resp.status}, {await resp.text()}"
+ f"钉钉媒体上传失败: {resp.status}, {await resp.text()}",
)
return ""
data = await resp.json()
if data.get("errcode") != 0:
logger.error(f"钉钉媒体上传失败: {data}")
return ""
- return cast(str, data.get("media_id", ""))
+ return cast("str", data.get("media_id", ""))
async def upload_image(self, image: Image) -> str:
image_file_path = await image.convert_to_file_path()
@@ -694,8 +702,8 @@ class DingtalkPlatformAdapter(Platform):
robot_code = self.client_id
# at_list: list[str] = []
- sender_id = cast(str, incoming_message.sender_id or "")
- sender_staff_id = cast(str, incoming_message.sender_staff_id or "")
+ sender_id = cast("str", incoming_message.sender_id or "")
+ sender_staff_id = cast("str", incoming_message.sender_staff_id or "")
normalized_sender_id = self._id_to_sid(sender_id)
# 现在用的发消息接口不支持 at
# for segment in message_chain.chain:
@@ -711,7 +719,7 @@ class DingtalkPlatformAdapter(Platform):
if incoming_message.conversation_type == "2":
await self.send_message_chain_to_group(
- open_conversation_id=cast(str, incoming_message.conversation_id),
+ open_conversation_id=cast("str", incoming_message.conversation_id),
robot_code=robot_code,
message_chain=message_chain,
# at_str=at_str,
diff --git a/astrbot/core/platform/sources/lark/app_registration.py b/astrbot/core/platform/sources/lark/app_registration.py
new file mode 100644
index 000000000..3420b426f
--- /dev/null
+++ b/astrbot/core/platform/sources/lark/app_registration.py
@@ -0,0 +1,205 @@
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import urlencode
+
+import aiohttp
+
+DEFAULT_FEISHU_OPEN_DOMAIN = "https://open.feishu.cn"
+DEFAULT_LARK_OPEN_DOMAIN = "https://open.larksuite.com"
+APP_REGISTRATION_PATH = "/oauth/v1/app/registration"
+
+
+@dataclass
+class LarkAppRegistrationEndpoints:
+ accounts_base: str
+ open_base: str
+ registration: str
+
+
+@dataclass
+class LarkAppRegistration:
+ device_code: str
+ user_code: str
+ verification_uri: str
+ verification_uri_complete: str
+ expires_in: int
+ interval: int
+
+
+def resolve_app_registration_endpoints(
+ domain: str,
+) -> LarkAppRegistrationEndpoints:
+ normalized = (domain or DEFAULT_FEISHU_OPEN_DOMAIN).strip().rstrip("/")
+ if normalized in {"feishu", DEFAULT_FEISHU_OPEN_DOMAIN}:
+ accounts_base = "https://accounts.feishu.cn"
+ open_base = DEFAULT_FEISHU_OPEN_DOMAIN
+ elif normalized in {"lark", DEFAULT_LARK_OPEN_DOMAIN}:
+ accounts_base = "https://accounts.larksuite.com"
+ open_base = DEFAULT_LARK_OPEN_DOMAIN
+ else:
+ open_base = normalized
+ accounts_base = normalized.replace("://open.", "://accounts.", 1)
+
+ return LarkAppRegistrationEndpoints(
+ accounts_base=accounts_base,
+ open_base=open_base,
+ registration=f"{accounts_base}{APP_REGISTRATION_PATH}",
+ )
+
+
+def _registration_data(raw: dict[str, Any]) -> dict[str, Any]:
+ data = raw.get("data")
+ if isinstance(data, dict):
+ return data
+ return raw
+
+
+def _string_field(data: dict[str, Any], key: str) -> str:
+ value = data.get(key)
+ if isinstance(value, str):
+ return value
+ return ""
+
+
+def _int_field(data: dict[str, Any], key: str, default: int) -> int:
+ value = data.get(key)
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return int(value)
+ return default
+
+
+async def _post_registration(
+ endpoint: str,
+ form: dict[str, str],
+) -> tuple[int, dict[str, Any]]:
+ timeout = aiohttp.ClientTimeout(total=15)
+ async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
+ async with session.post(
+ endpoint,
+ data=form,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ ) as response:
+ status = response.status
+ data = await response.json(content_type=None)
+ if not isinstance(data, dict):
+ raise RuntimeError("飞书应用创建响应格式异常")
+ return status, data
+
+
+def _raise_registration_error(status: int, raw: dict[str, Any], fallback: str) -> None:
+ data = _registration_data(raw)
+ if status < 400 and not raw.get("error") and not data.get("error"):
+ return
+ message = (
+ _string_field(raw, "error_description")
+ or _string_field(data, "error_description")
+ or _string_field(raw, "error")
+ or _string_field(data, "error")
+ or fallback
+ )
+ raise RuntimeError(message)
+
+
+async def request_app_registration(domain: str) -> LarkAppRegistration:
+ endpoints = resolve_app_registration_endpoints(domain)
+ status, raw = await _post_registration(
+ endpoints.registration,
+ {
+ "action": "begin",
+ "archetype": "PersonalAgent",
+ "auth_method": "client_secret",
+ "request_user_info": "open_id tenant_brand",
+ },
+ )
+ _raise_registration_error(status, raw, "发起扫码创建失败")
+ data = _registration_data(raw)
+ user_code = _string_field(data, "user_code")
+ verification_uri = _string_field(data, "verification_uri")
+ verification_uri_complete = _string_field(data, "verification_uri_complete")
+ if not verification_uri_complete and user_code:
+ verification_uri_complete = (
+ f"{endpoints.open_base}/page/cli?{urlencode({'user_code': user_code})}"
+ )
+
+ return LarkAppRegistration(
+ device_code=_string_field(data, "device_code"),
+ user_code=user_code,
+ verification_uri=verification_uri,
+ verification_uri_complete=verification_uri_complete,
+ expires_in=_int_field(data, "expires_in", 300),
+ interval=_int_field(data, "interval", 5),
+ )
+
+
+def _tenant_brand(data: dict[str, Any]) -> str:
+ user_info = data.get("user_info")
+ if isinstance(user_info, dict):
+ return _string_field(user_info, "tenant_brand")
+ return _string_field(data, "tenant_brand")
+
+
+async def poll_app_registration_once(
+ *,
+ domain: str,
+ device_code: str,
+) -> dict[str, Any]:
+ endpoints = resolve_app_registration_endpoints(domain)
+ status, raw = await _post_registration(
+ endpoints.registration,
+ {
+ "action": "poll",
+ "device_code": device_code,
+ },
+ )
+ data = _registration_data(raw)
+ error = _string_field(raw, "error") or _string_field(data, "error")
+ client_id = _string_field(data, "client_id")
+ client_secret = _string_field(data, "client_secret")
+ tenant_brand = _tenant_brand(data)
+
+ if status < 400 and not error and client_id:
+ if not client_secret and tenant_brand == "lark":
+ client_secret = await _poll_lark_secret(device_code)
+ if not client_secret:
+ return {"status": "error", "message": "应用创建成功但未获取到凭证"}
+ return {
+ "status": "created",
+ "app_id": client_id,
+ "app_secret": client_secret,
+ "tenant_brand": tenant_brand,
+ "domain": DEFAULT_LARK_OPEN_DOMAIN
+ if tenant_brand == "lark"
+ else DEFAULT_FEISHU_OPEN_DOMAIN,
+ }
+ if error == "authorization_pending":
+ return {"status": "pending"}
+ if error == "slow_down":
+ return {"status": "slow_down"}
+ if error == "access_denied":
+ return {"status": "denied", "message": "用户取消了扫码创建"}
+ if error in {"expired_token", "invalid_grant"}:
+ return {"status": "expired", "message": "扫码已过期,请再次创建"}
+
+ message = (
+ _string_field(raw, "error_description")
+ or _string_field(data, "error_description")
+ or error
+ or "获取扫码创建状态失败"
+ )
+ return {"status": "error", "message": message}
+
+
+async def _poll_lark_secret(device_code: str) -> str:
+ endpoints = resolve_app_registration_endpoints(DEFAULT_LARK_OPEN_DOMAIN)
+ status, raw = await _post_registration(
+ endpoints.registration,
+ {
+ "action": "poll",
+ "device_code": device_code,
+ },
+ )
+ if status >= 400 or raw.get("error"):
+ return ""
+ return _string_field(_registration_data(raw), "client_secret")
diff --git a/astrbot/core/platform/sources/lark/bot_info.py b/astrbot/core/platform/sources/lark/bot_info.py
new file mode 100644
index 000000000..d8db8141b
--- /dev/null
+++ b/astrbot/core/platform/sources/lark/bot_info.py
@@ -0,0 +1,94 @@
+from dataclasses import dataclass
+from typing import Any
+
+import aiohttp
+
+from .app_registration import DEFAULT_FEISHU_OPEN_DOMAIN, DEFAULT_LARK_OPEN_DOMAIN
+
+TENANT_ACCESS_TOKEN_INTERNAL_PATH = "/open-apis/auth/v3/tenant_access_token/internal"
+BOT_INFO_PATH = "/open-apis/bot/v3/info"
+
+
+@dataclass
+class LarkBotInfo:
+ app_name: str
+ open_id: str
+
+
+def _open_base(domain: str) -> str:
+ normalized = (domain or DEFAULT_FEISHU_OPEN_DOMAIN).strip().rstrip("/")
+ if normalized in {"feishu", DEFAULT_FEISHU_OPEN_DOMAIN}:
+ return DEFAULT_FEISHU_OPEN_DOMAIN
+ if normalized in {"lark", DEFAULT_LARK_OPEN_DOMAIN}:
+ return DEFAULT_LARK_OPEN_DOMAIN
+ return normalized
+
+
+def _string_field(data: dict[str, Any], key: str) -> str:
+ value = data.get(key)
+ return value if isinstance(value, str) else ""
+
+
+async def _post_json(
+ endpoint: str,
+ payload: dict[str, str],
+) -> dict[str, Any]:
+ timeout = aiohttp.ClientTimeout(total=15)
+ async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
+ async with session.post(endpoint, json=payload) as response:
+ data = await response.json(content_type=None)
+ if not isinstance(data, dict):
+ raise RuntimeError("飞书接口响应格式异常")
+ return data
+
+
+async def _get_json(
+ endpoint: str,
+ *,
+ headers: dict[str, str],
+) -> dict[str, Any]:
+ timeout = aiohttp.ClientTimeout(total=15)
+ async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
+ async with session.get(endpoint, headers=headers) as response:
+ data = await response.json(content_type=None)
+ if not isinstance(data, dict):
+ raise RuntimeError("飞书接口响应格式异常")
+ return data
+
+
+async def request_lark_bot_info(
+ *,
+ domain: str,
+ app_id: str,
+ app_secret: str,
+) -> LarkBotInfo:
+ open_base = _open_base(domain)
+ token_data = await _post_json(
+ f"{open_base}{TENANT_ACCESS_TOKEN_INTERNAL_PATH}",
+ {
+ "app_id": app_id,
+ "app_secret": app_secret,
+ },
+ )
+ if token_data.get("code") != 0:
+ raise RuntimeError(_string_field(token_data, "msg") or "获取飞书访问令牌失败")
+
+ tenant_access_token = _string_field(token_data, "tenant_access_token")
+ if not tenant_access_token:
+ raise RuntimeError("飞书访问令牌响应缺少 tenant_access_token")
+
+ bot_data = await _get_json(
+ f"{open_base}{BOT_INFO_PATH}",
+ headers={"Authorization": f"Bearer {tenant_access_token}"},
+ )
+ if bot_data.get("code") != 0:
+ raise RuntimeError(_string_field(bot_data, "msg") or "获取飞书机器人信息失败")
+
+ bot = bot_data.get("bot")
+ if not isinstance(bot, dict):
+ raise RuntimeError("飞书机器人信息响应缺少 bot 字段")
+
+ return LarkBotInfo(
+ app_name=_string_field(bot, "app_name"),
+ open_id=_string_field(bot, "open_id"),
+ )
diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py
index 352251155..f2a5d7d3f 100644
--- a/astrbot/core/platform/sources/lark/lark_adapter.py
+++ b/astrbot/core/platform/sources/lark/lark_adapter.py
@@ -27,6 +27,7 @@ from astrbot.core.platform.register import register_platform_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.webhook_utils import log_webhook_info
+from .bot_info import request_lark_bot_info
from .lark_event import LarkMessageEvent
from .server import LarkWebhookServer
@@ -48,10 +49,14 @@ class LarkPlatformAdapter(Platform):
self.appsecret = platform_config["app_secret"]
self.domain = platform_config.get("domain", lark.FEISHU_DOMAIN)
self.bot_name = platform_config.get("lark_bot_name", "astrbot")
- self.connection_mode = platform_config.get("lark_connection_mode", "socket")
+ self.bot_open_id = ""
if not self.bot_name:
logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。")
+ # socket or webhook
+ self.connection_mode = platform_config.get("lark_connection_mode", "socket")
+
+ # 初始化 WebSocket 长连接相关配置
async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1) -> None:
await self.convert_msg(event)
@@ -470,7 +475,7 @@ class LarkPlatformAdapter(Platform):
)
if message.chat_type == "group":
abm.group_id = message.chat_id
- abm.self_id = self.bot_name
+ abm.self_id = self.bot_open_id or self.bot_name
abm.message_str = ""
at_list = {}
if message.parent_id:
@@ -483,9 +488,11 @@ class LarkPlatformAdapter(Platform):
continue
open_id = m.id.open_id or ""
at_list[m.key] = Comp.At(qq=open_id, name=m.name)
- if m.name == self.bot_name:
- if m.id.open_id is not None:
- abm.self_id = m.id.open_id
+
+ if (self.bot_open_id and open_id == self.bot_open_id) or (
+ m.name == self.bot_name
+ ):
+ abm.self_id = open_id or self.bot_open_id or self.bot_name
if message.content is None:
logger.warning("[Lark] 消息内容为空")
return
@@ -562,6 +569,11 @@ class LarkPlatformAdapter(Platform):
logger.error(f"[Lark Webhook] 处理事件失败: {e}", exc_info=True)
async def run(self) -> None:
+ try:
+ await self._refresh_bot_info()
+ except Exception as e:
+ logger.error(f"[Lark] 启动时获取机器人信息失败: {e}", exc_info=True)
+
if self.connection_mode == "webhook":
if self.webhook_server is None:
logger.error("[Lark] Webhook 模式已启用,但 webhook_server 未初始化")
@@ -580,6 +592,17 @@ class LarkPlatformAdapter(Platform):
return ({"error": "Webhook server not initialized"}, 500)
return await self.webhook_server.handle_callback(request)
+ async def _refresh_bot_info(self) -> None:
+ bot_info = await request_lark_bot_info(
+ domain=self.domain,
+ app_id=self.appid,
+ app_secret=self.appsecret,
+ )
+ if bot_info.app_name:
+ self.bot_name = bot_info.app_name
+ if bot_info.open_id:
+ self.bot_open_id = bot_info.open_id
+
async def terminate(self) -> None:
if self.connection_mode == "socket":
await self.client._disconnect()
diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py
index 13b7ddec9..280e7a069 100644
--- a/astrbot/core/platform/sources/lark/lark_event.py
+++ b/astrbot/core/platform/sources/lark/lark_event.py
@@ -74,6 +74,7 @@ class LarkMessageEvent(AstrMessageEvent):
Returns:
是否发送成功
+
"""
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化")
@@ -89,7 +90,7 @@ class LarkMessageEvent(AstrMessageEvent):
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.reply_in_thread(False)
- .build()
+ .build(),
)
.build()
)
@@ -115,7 +116,7 @@ class LarkMessageEvent(AstrMessageEvent):
.content(content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
- .build()
+ .build(),
)
.build()
)
@@ -145,6 +146,7 @@ class LarkMessageEvent(AstrMessageEvent):
Returns:
成功返回file_key,失败返回None
+
"""
if not path or not os.path.exists(path):
logger.error(f"[Lark] 文件不存在: {path}")
@@ -174,7 +176,7 @@ class LarkMessageEvent(AstrMessageEvent):
if not response.success():
logger.error(
- f"[Lark] 无法上传文件({response.code}): {response.msg}"
+ f"[Lark] 无法上传文件({response.code}): {response.msg}",
)
return None
@@ -207,7 +209,7 @@ class LarkMessageEvent(AstrMessageEvent):
file_path = comp.file.replace("file:///", "")
elif comp.file and comp.file.startswith("http"):
image_file_path = await download_image_by_url(comp.file)
- file_path = image_file_path if image_file_path else ""
+ file_path = image_file_path or ""
elif comp.file and comp.file.startswith("base64://"):
base64_str = comp.file.removeprefix("base64://")
image_data = base64.b64decode(base64_str)
@@ -220,7 +222,7 @@ class LarkMessageEvent(AstrMessageEvent):
with open(file_path, "wb") as f:
f.write(BytesIO(image_data).getvalue())
else:
- file_path = comp.file if comp.file else ""
+ file_path = comp.file or ""
if image_file is None:
if not file_path:
@@ -307,7 +309,7 @@ class LarkMessageEvent(AstrMessageEvent):
{
"tag": "markdown",
"content": reasoning_content,
- }
+ },
],
}
@@ -321,8 +323,8 @@ class LarkMessageEvent(AstrMessageEvent):
reasoning_content=reasoning_content,
title=title,
expanded=False,
- )
- ]
+ ),
+ ],
},
}
@@ -341,7 +343,7 @@ class LarkMessageEvent(AstrMessageEvent):
reasoning_content=reasoning_content,
title=str(comp.data.get("title", "💭 Thinking")),
expanded=bool(comp.data.get("expanded", False)),
- )
+ ),
)
elif isinstance(comp, Plain):
if comp.text:
@@ -379,9 +381,9 @@ class LarkMessageEvent(AstrMessageEvent):
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
- .build()
+ .build(),
)
- .build()
+ .build(),
)
except Exception as e:
logger.error(f"[Lark] 创建卡片失败: {e}")
@@ -446,6 +448,7 @@ class LarkMessageEvent(AstrMessageEvent):
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型,如 'open_id', 'chat_id'(用于主动发送)
+
"""
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化")
@@ -554,17 +557,29 @@ class LarkMessageEvent(AstrMessageEvent):
# 发送附件
for file_comp in file_components:
await LarkMessageEvent._send_file_message(
- file_comp, lark_client, reply_message_id, receive_id, receive_id_type
+ file_comp,
+ lark_client,
+ reply_message_id,
+ receive_id,
+ receive_id_type,
)
for audio_comp in audio_components:
await LarkMessageEvent._send_audio_message(
- audio_comp, lark_client, reply_message_id, receive_id, receive_id_type
+ audio_comp,
+ lark_client,
+ reply_message_id,
+ receive_id,
+ receive_id_type,
)
for media_comp in media_components:
await LarkMessageEvent._send_media_message(
- media_comp, lark_client, reply_message_id, receive_id, receive_id_type
+ media_comp,
+ lark_client,
+ reply_message_id,
+ receive_id,
+ receive_id_type,
)
async def send(self, message: MessageChain) -> None:
@@ -592,10 +607,13 @@ class LarkMessageEvent(AstrMessageEvent):
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
+
"""
file_path = file_comp.file or ""
file_key = await LarkMessageEvent._upload_lark_file(
- lark_client, path=file_path, file_type="stream"
+ lark_client,
+ path=file_path,
+ file_type="stream",
)
if not file_key:
return
@@ -626,6 +644,7 @@ class LarkMessageEvent(AstrMessageEvent):
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
+
"""
# 获取音频文件路径
try:
@@ -699,6 +718,7 @@ class LarkMessageEvent(AstrMessageEvent):
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
+
"""
# 获取视频文件路径
try:
@@ -803,8 +823,8 @@ class LarkMessageEvent(AstrMessageEvent):
"tag": "markdown",
"content": "",
"element_id": "markdown_1",
- }
- ]
+ },
+ ],
},
}
@@ -814,7 +834,7 @@ class LarkMessageEvent(AstrMessageEvent):
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
- .build()
+ .build(),
)
.build()
)
@@ -827,7 +847,7 @@ class LarkMessageEvent(AstrMessageEvent):
if not response.success():
logger.error(
- f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
+ f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}",
)
return None
@@ -880,7 +900,7 @@ class LarkMessageEvent(AstrMessageEvent):
.content(content)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
- .build()
+ .build(),
)
.build()
)
@@ -920,7 +940,7 @@ class LarkMessageEvent(AstrMessageEvent):
.settings(settings_json)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
- .build()
+ .build(),
)
.build()
)
@@ -950,7 +970,7 @@ class LarkMessageEvent(AstrMessageEvent):
await self.send(buffer)
asyncio.create_task(
- Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
+ Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
)
self._has_send_oper = True
@@ -1003,7 +1023,7 @@ class LarkMessageEvent(AstrMessageEvent):
buffer.squash_plain()
await self.send(buffer)
asyncio.create_task(
- Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
+ Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
)
self._has_send_oper = True
@@ -1050,7 +1070,7 @@ class LarkMessageEvent(AstrMessageEvent):
card_id = await self._create_streaming_card()
if not card_id:
logger.warning(
- "[Lark] 无法创建流式卡片,回退到非流式发送"
+ "[Lark] 无法创建流式卡片,回退到非流式发送",
)
await _consume_rest_and_fallback(generator, delta)
return
@@ -1061,7 +1081,7 @@ class LarkMessageEvent(AstrMessageEvent):
)
if not sent:
logger.error(
- "[Lark] 发送流式卡片消息失败,回退到非流式发送"
+ "[Lark] 发送流式卡片消息失败,回退到非流式发送",
)
await _consume_rest_and_fallback(generator, delta)
return
@@ -1081,8 +1101,9 @@ class LarkMessageEvent(AstrMessageEvent):
if not fallback_used:
asyncio.create_task(
Metric.upload(
- msg_event_tick=1, adapter_name=self.platform_meta.name
- )
+ msg_event_tick=1,
+ adapter_name=self.platform_meta.name,
+ ),
)
self._has_send_oper = True
return
@@ -1091,6 +1112,6 @@ class LarkMessageEvent(AstrMessageEvent):
# 内联父类 send_streaming 的副作用
asyncio.create_task(
- Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
+ Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
)
self._has_send_oper = True
diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py
index b4d494b34..f5a7dfab8 100644
--- a/astrbot/core/platform/sources/webchat/webchat_adapter.py
+++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py
@@ -89,7 +89,7 @@ class WebChatAdapter(Platform):
) -> None:
conversation_id = _extract_conversation_id(session.session_id)
active_request_ids = self._webchat_queue_mgr.list_back_request_ids(
- conversation_id
+ conversation_id,
)
stream_request_ids = [
req_id for req_id in active_request_ids if not req_id.startswith("ws_sub_")
@@ -154,7 +154,8 @@ class WebChatAdapter(Platform):
)
async def _get_message_history(
- self, message_id: int
+ self,
+ message_id: int,
) -> PlatformMessageHistory | None:
return await db_helper.get_platform_message_history_by_id(message_id)
@@ -173,6 +174,7 @@ class WebChatAdapter(Platform):
Returns:
tuple[list, list[str]]: (消息组件列表, 纯文本列表)
+
"""
async def get_reply_parts(
@@ -247,12 +249,14 @@ class WebChatAdapter(Platform):
message_event.set_extra("selected_provider", payload.get("selected_provider"))
message_event.set_extra("selected_model", payload.get("selected_model"))
message_event.set_extra(
- "enable_streaming", payload.get("enable_streaming", True)
+ "enable_streaming",
+ payload.get("enable_streaming", True),
)
message_event.set_extra("action_type", payload.get("action_type"))
message_event.set_extra("llm_checkpoint_id", payload.get("llm_checkpoint_id"))
message_event.set_extra(
- "thread_selected_text", payload.get("thread_selected_text")
+ "thread_selected_text",
+ payload.get("thread_selected_text"),
)
self.commit_event(message_event)
diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py
index 31436ebf2..1b191bb56 100644
--- a/astrbot/core/platform/sources/wecom/wecom_adapter.py
+++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py
@@ -66,7 +66,7 @@ def _extract_wecom_media_filename(disposition: str | None) -> str | None:
class WecomServer:
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
self.server = quart.Quart(__name__)
- self.port = int(cast(str, config.get("port")))
+ self.port = int(cast("str", config.get("port")))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
self.server.add_url_rule(
"/callback/command",
@@ -101,6 +101,7 @@ class WecomServer:
Returns:
验证响应
+
"""
logger.info(f"验证请求有效性: {request.args}")
args = request.args
@@ -129,6 +130,7 @@ class WecomServer:
Returns:
响应内容
+
"""
data = await request.get_data()
msg_signature = request.args.get("msg_signature")
@@ -140,7 +142,7 @@ class WecomServer:
logger.error("解密失败,签名异常,请检查配置。")
raise
else:
- msg = cast(BaseMessage, parse_message(xml))
+ msg = cast("BaseMessage", parse_message(xml))
logger.info(f"解析成功: {msg}")
if self.callback:
@@ -355,8 +357,7 @@ class WecomPlatformAdapter(Platform):
# 根据请求方法分发到不同的处理函数
if request.method == "GET":
return await self.server.handle_verify(request)
- else:
- return await self.server.handle_callback(request)
+ return await self.server.handle_callback(request)
async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None:
abm = AstrBotMessage()
@@ -366,11 +367,11 @@ class WecomPlatformAdapter(Platform):
abm.message = [Plain(msg.content)]
abm.type = MessageType.FRIEND_MESSAGE
abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
+ cast("str", msg.source),
+ cast("str", msg.source),
)
abm.message_id = str(msg.id)
- abm.timestamp = int(cast(int | str, msg.time))
+ abm.timestamp = int(cast("int | str", msg.time))
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, ImageMessage):
@@ -379,11 +380,11 @@ class WecomPlatformAdapter(Platform):
abm.message = [Image(file=msg.image, url=msg.image)]
abm.type = MessageType.FRIEND_MESSAGE
abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
+ cast("str", msg.source),
+ cast("str", msg.source),
)
abm.message_id = str(msg.id)
- abm.timestamp = int(cast(int | str, msg.time))
+ abm.timestamp = int(cast("int | str", msg.time))
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, VoiceMessage):
@@ -410,11 +411,11 @@ class WecomPlatformAdapter(Platform):
abm.message = [Record(file=path_wav, url=path_wav)]
abm.type = MessageType.FRIEND_MESSAGE
abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
+ cast("str", msg.source),
+ cast("str", msg.source),
)
abm.message_id = str(msg.id)
- abm.timestamp = int(cast(int | str, msg.time))
+ abm.timestamp = int(cast("int | str", msg.time))
abm.session_id = abm.sender.user_id
abm.raw_message = msg
else:
@@ -427,7 +428,7 @@ class WecomPlatformAdapter(Platform):
async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None:
msgtype = msg.get("msgtype")
- external_userid = cast(str, msg.get("external_userid"))
+ external_userid = cast("str", msg.get("external_userid"))
abm = AstrBotMessage()
abm.raw_message = msg
abm.raw_message["_wechat_kf_flag"] = None # 方便处理
diff --git a/astrbot/core/platform/sources/weixin_oc/login_registration.py b/astrbot/core/platform/sources/weixin_oc/login_registration.py
new file mode 100644
index 000000000..4310924d9
--- /dev/null
+++ b/astrbot/core/platform/sources/weixin_oc/login_registration.py
@@ -0,0 +1,167 @@
+from dataclasses import dataclass
+from typing import Any
+
+from .weixin_oc_client import WeixinOCClient
+
+DEFAULT_WEIXIN_OC_BASE_URL = "https://ilinkai.weixin.qq.com"
+DEFAULT_WEIXIN_OC_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
+DEFAULT_WEIXIN_OC_BOT_TYPE = "3"
+DEFAULT_WEIXIN_OC_QR_POLL_INTERVAL = 1
+DEFAULT_WEIXIN_OC_LONG_POLL_TIMEOUT_MS = 35_000
+DEFAULT_WEIXIN_OC_API_TIMEOUT_MS = 15_000
+
+
+@dataclass
+class WeixinOCLoginRegistration:
+ qrcode: str
+ qrcode_img_content: str
+ interval: int
+
+
+def normalize_weixin_oc_base_url(base_url: str | None) -> str:
+ return (base_url or DEFAULT_WEIXIN_OC_BASE_URL).strip().rstrip("/")
+
+
+def _string_field(data: dict[str, Any], key: str) -> str:
+ value = data.get(key)
+ if isinstance(value, str):
+ return value.strip()
+ return ""
+
+
+def _int_config(value: Any, default: int, minimum: int) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ parsed = default
+ return max(parsed, minimum)
+
+
+def weixin_oc_login_result(
+ data: dict[str, Any],
+ *,
+ default_base_url: str,
+) -> dict[str, Any]:
+ raw_status = _string_field(data, "status") or "wait"
+ if raw_status == "confirmed":
+ bot_token = _string_field(data, "bot_token")
+ if not bot_token:
+ return {"status": "error", "message": "登录成功但未返回 token"}
+ base_url = _string_field(data, "baseurl") or default_base_url
+ return {
+ "status": "created",
+ "qr_status": raw_status,
+ "weixin_oc_token": bot_token,
+ "weixin_oc_account_id": _string_field(data, "ilink_bot_id"),
+ "weixin_oc_base_url": normalize_weixin_oc_base_url(base_url),
+ "weixin_oc_user_id": _string_field(data, "ilink_user_id"),
+ }
+ if raw_status == "expired":
+ return {"status": "expired", "qr_status": raw_status, "message": "二维码已过期"}
+ if raw_status in {"cancel", "canceled", "denied"}:
+ return {"status": "denied", "qr_status": raw_status, "message": "用户取消登录"}
+ return {"status": "pending", "qr_status": raw_status}
+
+
+def _client(
+ *,
+ adapter_id: str,
+ base_url: str,
+ api_timeout_ms: int,
+) -> WeixinOCClient:
+ return WeixinOCClient(
+ adapter_id=adapter_id,
+ base_url=base_url,
+ cdn_base_url=DEFAULT_WEIXIN_OC_CDN_BASE_URL,
+ api_timeout_ms=api_timeout_ms,
+ )
+
+
+async def request_weixin_oc_login_qr(
+ platform_config: dict[str, Any],
+) -> WeixinOCLoginRegistration:
+ base_url = normalize_weixin_oc_base_url(
+ _string_field(platform_config, "weixin_oc_base_url"),
+ )
+ bot_type = _string_field(platform_config, "weixin_oc_bot_type")
+ if not bot_type:
+ bot_type = DEFAULT_WEIXIN_OC_BOT_TYPE
+ api_timeout_ms = _int_config(
+ platform_config.get("weixin_oc_api_timeout_ms"),
+ DEFAULT_WEIXIN_OC_API_TIMEOUT_MS,
+ 1_000,
+ )
+ interval = _int_config(
+ platform_config.get("weixin_oc_qr_poll_interval"),
+ DEFAULT_WEIXIN_OC_QR_POLL_INTERVAL,
+ 1,
+ )
+
+ client = _client(
+ adapter_id=str(platform_config.get("id") or "weixin_oc"),
+ base_url=base_url,
+ api_timeout_ms=api_timeout_ms,
+ )
+ try:
+ data = await client.request_json(
+ "GET",
+ "ilink/bot/get_bot_qrcode",
+ params={"bot_type": bot_type},
+ token_required=False,
+ timeout_ms=15_000,
+ )
+ finally:
+ await client.close()
+
+ qrcode = _string_field(data, "qrcode")
+ qrcode_img_content = _string_field(data, "qrcode_img_content")
+ if not qrcode or not qrcode_img_content:
+ raise RuntimeError("个人微信二维码响应格式异常")
+
+ return WeixinOCLoginRegistration(
+ qrcode=qrcode,
+ qrcode_img_content=qrcode_img_content,
+ interval=interval,
+ )
+
+
+async def poll_weixin_oc_login_once(
+ *,
+ platform_config: dict[str, Any],
+ qrcode: str,
+) -> dict[str, Any]:
+ if not qrcode:
+ raise ValueError("Missing qrcode")
+
+ base_url = normalize_weixin_oc_base_url(
+ _string_field(platform_config, "weixin_oc_base_url"),
+ )
+ api_timeout_ms = _int_config(
+ platform_config.get("weixin_oc_api_timeout_ms"),
+ DEFAULT_WEIXIN_OC_API_TIMEOUT_MS,
+ 1_000,
+ )
+ long_poll_timeout_ms = _int_config(
+ platform_config.get("weixin_oc_long_poll_timeout_ms"),
+ DEFAULT_WEIXIN_OC_LONG_POLL_TIMEOUT_MS,
+ 1_000,
+ )
+
+ client = _client(
+ adapter_id=str(platform_config.get("id") or "weixin_oc"),
+ base_url=base_url,
+ api_timeout_ms=api_timeout_ms,
+ )
+ try:
+ data = await client.request_json(
+ "GET",
+ "ilink/bot/get_qrcode_status",
+ params={"qrcode": qrcode},
+ token_required=False,
+ timeout_ms=long_poll_timeout_ms,
+ headers={"iLink-App-ClientVersion": "1"},
+ )
+ finally:
+ await client.close()
+
+ return weixin_oc_login_result(data, default_base_url=base_url)
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 55c41aa98..cbba01894 100644
--- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
+++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
@@ -61,6 +61,7 @@ class TypingSessionState:
@register_platform_adapter("weixin_oc", "个人微信", support_streaming_message=False)
class WeixinOCAdapter(Platform):
+ SESSION_TIMEOUT_ERRCODE = -14
IMAGE_ITEM_TYPE = 2
VOICE_ITEM_TYPE = 3
FILE_ITEM_TYPE = 4
@@ -775,6 +776,50 @@ class WeixinOCAdapter(Platform):
)
return True
+ def _build_cache_components_from_items(
+ self,
+ item_list: list[dict[str, Any]],
+ ) -> list[Any]:
+ components: list[Any] = []
+ for item in item_list:
+ item_type = int(item.get("type") or 0)
+ if item_type != 1:
+ continue
+ text = str(item.get("text_item", {}).get("text", "")).strip()
+ if text:
+ components.append(Plain(text))
+ return components
+
+ @staticmethod
+ def _is_successful_api_payload(payload: dict[str, Any]) -> bool:
+ ret = payload.get("ret", 0)
+ errcode = payload.get("errcode", 0)
+ return int(ret or 0) == 0 and int(errcode or 0) == 0
+
+ @staticmethod
+ def _format_api_error(payload: dict[str, Any]) -> str:
+ ret = int(payload.get("ret") or 0)
+ errcode = int(payload.get("errcode") or 0)
+ errmsg = str(payload.get("errmsg", ""))
+ return f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
+
+ @staticmethod
+ def _api_errcode(payload: dict[str, Any]) -> int:
+ return int(payload.get("errcode") or 0)
+
+ async def _handle_inbound_session_timeout(self) -> None:
+ logger.warning(
+ "weixin_oc(%s): session timed out, clearing login state and waiting for QR login.",
+ self.meta().id,
+ )
+ self.token = None
+ self.account_id = None
+ self._sync_buf = ""
+ self._context_tokens = {}
+ self._context_tokens_dirty = False
+ self._login_session = None
+ await self._save_account_state()
+
async def _send_media_segment(
self,
user_id: str,
@@ -816,7 +861,12 @@ class WeixinOCAdapter(Platform):
file_name,
)
except Exception as e:
- logger.error("weixin_oc(%s): prepare media failed: %s", self.meta().id, e)
+ logger.error(
+ "weixin_oc(%s): prepare media failed: %s",
+ self.meta().id,
+ e,
+ exc_info=True,
+ )
return False
if text:
await self._send_items_to_session(
@@ -1065,6 +1115,10 @@ class WeixinOCAdapter(Platform):
self.meta().id,
self._last_inbound_error,
)
+ if self._api_errcode(data) == self.SESSION_TIMEOUT_ERRCODE:
+ await self._handle_inbound_session_timeout()
+ return
+ await asyncio.sleep(5)
return
should_save_state = self._context_tokens_dirty
@@ -1115,17 +1169,20 @@ class WeixinOCAdapter(Platform):
target_user = session.session_id
pending_text = ""
has_supported_segment = False
+ failed_segments = 0
for segment in message_chain.chain:
if isinstance(segment, Plain):
pending_text += segment.text
continue
if isinstance(segment, (Image, Video, File)):
has_supported_segment = True
- await self._send_media_segment(
+ sent = await self._send_media_segment(
target_user,
segment,
text=pending_text.strip() or None,
)
+ if not sent:
+ failed_segments += 1
pending_text = ""
continue
logger.debug(
@@ -1135,12 +1192,19 @@ class WeixinOCAdapter(Platform):
)
if pending_text:
has_supported_segment = True
- await self._send_to_session(target_user, pending_text.strip())
+ sent = await self._send_to_session(target_user, pending_text.strip())
+ if not sent:
+ failed_segments += 1
if not has_supported_segment:
logger.warning(
"weixin_oc(%s): outbound message ignored, no supported segments",
self.meta().id,
)
+ if failed_segments:
+ raise RuntimeError(
+ f"weixin_oc({self.meta().id}, target_user={target_user}) "
+ f"failed to send {failed_segments} message segment(s)",
+ )
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py
index 8e12683ff..8129e70d1 100644
--- a/astrbot/core/provider/entities.py
+++ b/astrbot/core/provider/entities.py
@@ -480,7 +480,7 @@ class LLMResponse:
),
# the extra_content will not serialize if it's None when calling ToolCall.model_dump()
extra_content=self.tools_call_extra_content.get(
- self.tools_call_ids[idx]
+ self.tools_call_ids[idx],
),
),
)
diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py
index 042ca2ec4..f15ee0ed8 100644
--- a/astrbot/core/provider/manager.py
+++ b/astrbot/core/provider/manager.py
@@ -484,6 +484,14 @@ class ProviderManager:
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
+ case "nvidia_embedding":
+ from .sources.nvidia_embedding_source import (
+ NvidiaEmbeddingProvider as NvidiaEmbeddingProvider,
+ )
+ case "ollama_embedding":
+ from .sources.ollama_embedding_source import (
+ OllamaEmbeddingProvider as OllamaEmbeddingProvider,
+ )
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py
index e13a0fc18..bafb49100 100644
--- a/astrbot/core/provider/sources/gemini_source.py
+++ b/astrbot/core/provider/sources/gemini_source.py
@@ -11,6 +11,7 @@ from urllib.parse import urlparse
import aiofiles
import anyio
+import httpx
from google import genai
from google.genai import types
from google.genai.errors import APIError
@@ -66,6 +67,9 @@ class ProviderGoogleGenAI(Provider):
self.api_base: str | None = provider_config.get("api_base", None)
if self.api_base and self.api_base.endswith("/"):
self.api_base = self.api_base[:-1]
+
+ self._http_client: httpx.AsyncClient | None = None
+ self._stale_http_clients: list[httpx.AsyncClient] = []
self._init_client()
self.set_model(provider_config.get("model", "unknown"))
self._init_safety_settings()
@@ -77,9 +81,29 @@ class ProviderGoogleGenAI(Provider):
base_url=self.api_base,
timeout=self.timeout * 1000,
)
+
+ # 强制使用 httpx 作为异步 HTTP 后端,避免 aiohttp 响应类型兼容问题 (#7564)
+ # httpx.AsyncClient 的 timeout 单位为秒(与 HttpOptions 的毫秒不同)
+ async_client_kwargs: dict = {
+ "base_url": self.api_base,
+ "timeout": self.timeout,
+ }
if proxy:
- http_options.async_client_args = {"proxy": proxy}
- logger.info(f"[Gemini] 使用代理: {proxy}")
+ async_client_kwargs["proxy"] = proxy
+ async_client_kwargs["trust_env"] = False
+ logger.info("[Gemini] 使用代理")
+ else:
+ async_client_kwargs["trust_env"] = True
+
+ # Track the previous client so it can be closed in terminate() instead
+ # of leaking when _init_client is called again (e.g. via set_key).
+ # Only the most recent stale client is kept to avoid unbounded growth.
+ if self._http_client is not None:
+ self._stale_http_clients = [self._http_client]
+
+ self._http_client = httpx.AsyncClient(**async_client_kwargs)
+ http_options.httpx_async_client = self._http_client
+
self.client = genai.Client(
api_key=self.chosen_api_key,
http_options=http_options,
@@ -948,6 +972,30 @@ class ProviderGoogleGenAI(Provider):
image_bs64 = base64.b64encode(await f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
+ async def _close_httpx_client(self, client: httpx.AsyncClient | None) -> None:
+ """Safely close an httpx.AsyncClient, swallowing errors for idempotency."""
+ if client is None:
+ return
+ try:
+ await client.aclose()
+ except Exception as e:
+ # Idempotent: ignore errors from already-closed or broken clients,
+ # but log at debug to aid diagnosing unexpected shutdown issues.
+ logger.debug(f"[Gemini] Ignored error while closing httpx client: {e}")
+
async def terminate(self) -> None:
- if self.client:
- await self.client.aclose()
+ # Close the active Gemini client (external httpx client is managed
+ # separately so genai.Client.aclose skips it).
+ if self.client is not None:
+ try:
+ await self.client.aclose()
+ except Exception:
+ pass
+ self.client = None
+
+ # Close all tracked httpx clients (stale + current).
+ for client in self._stale_http_clients:
+ await self._close_httpx_client(client)
+ self._stale_http_clients.clear()
+ await self._close_httpx_client(self._http_client)
+ self._http_client = None
diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py
index afa0ade69..1c472adb8 100644
--- a/astrbot/core/provider/sources/minimax_tts_api_source.py
+++ b/astrbot/core/provider/sources/minimax_tts_api_source.py
@@ -37,12 +37,21 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
"minimax-is-timber-weight",
False,
)
- self.timber_weight: list[dict[str, str | int]] = json.loads(
- provider_config.get(
- "minimax-timber-weight",
- '[{"voice_id": "Chinese (Mandarin)_Warm_Girl", "weight": 1}]',
- ),
- )
+ default_timber_weight = [
+ {"voice_id": "Chinese (Mandarin)_Warm_Girl", "weight": 1},
+ ]
+ raw_timber_weight = provider_config.get("minimax-timber-weight", "")
+ if not raw_timber_weight:
+ self.timber_weight = default_timber_weight
+ else:
+ try:
+ self.timber_weight = json.loads(raw_timber_weight)
+ except json.JSONDecodeError:
+ logger.warning(
+ "MiniMax TTS 权重配置解析失败,将使用默认值。 raw_value: %s",
+ raw_timber_weight,
+ )
+ self.timber_weight = default_timber_weight
self.voice_setting: dict = {
"speed": provider_config.get("minimax-voice-speed", 1.0),
diff --git a/astrbot/core/provider/sources/nvidia_embedding_source.py b/astrbot/core/provider/sources/nvidia_embedding_source.py
new file mode 100644
index 000000000..41cc7455c
--- /dev/null
+++ b/astrbot/core/provider/sources/nvidia_embedding_source.py
@@ -0,0 +1,138 @@
+import aiohttp
+
+from astrbot import logger
+
+from ..entities import ProviderType
+from ..provider import EmbeddingProvider
+from ..register import register_provider_adapter
+
+
+@register_provider_adapter(
+ "nvidia_embedding",
+ "NVIDIA NIM Embedding 提供商适配器",
+ provider_type=ProviderType.EMBEDDING,
+)
+class NvidiaEmbeddingProvider(EmbeddingProvider):
+ def __init__(self, provider_config: dict, provider_settings: dict) -> None:
+ super().__init__(provider_config, provider_settings)
+ self.provider_config = provider_config
+ self.provider_settings = provider_settings
+
+ self.api_key = provider_config.get("embedding_api_key", "")
+ self.base_url = (
+ provider_config.get(
+ "embedding_api_base",
+ "https://integrate.api.nvidia.com/v1",
+ )
+ .rstrip("/")
+ .removesuffix("/embeddings")
+ )
+ self.timeout = int(provider_config.get("timeout", 20))
+ self.model = provider_config.get(
+ "embedding_model",
+ "nvidia/llama-nemotron-embed-1b-v2",
+ )
+ self.input_type = provider_config.get("input_type", "passage")
+
+ proxy = provider_config.get("proxy", "")
+ self.proxy = proxy
+ if proxy:
+ logger.info(f"[NVIDIA Embedding] Using proxy: {proxy}")
+
+ self.client = None
+ self.set_model(self.model)
+
+ async def _get_client(self):
+ if self.client is None or self.client.closed:
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
+ self.client = aiohttp.ClientSession(
+ headers=headers,
+ timeout=timeout,
+ )
+ return self.client
+
+ def _build_payload(self, text: str | list[str]) -> dict:
+ if isinstance(text, str):
+ input_text = [text]
+ else:
+ input_text = text
+
+ return {
+ "input": input_text,
+ "model": self.model,
+ "input_type": self.input_type,
+ "encoding_format": "float",
+ }
+
+ def _parse_response(self, response_data: dict) -> list[list[float]]:
+ data = response_data.get("data", [])
+ embeddings = []
+ for item in data:
+ embedding = item.get("embedding", [])
+ embeddings.append(embedding)
+ return embeddings
+
+ async def get_embedding(self, text: str) -> list[float]:
+ embeddings = await self.get_embeddings([text])
+ return embeddings[0] if embeddings else []
+
+ async def get_embeddings(self, text: list[str]) -> list[list[float]]:
+ client = await self._get_client()
+ if not client or client.closed:
+ raise Exception("[NVIDIA Embedding] Client session not initialized")
+
+ payload = self._build_payload(text)
+ request_url = f"{self.base_url}/embeddings"
+
+ try:
+ async with client.post(
+ request_url,
+ json=payload,
+ proxy=self.proxy or None,
+ ) as response:
+ if response.status != 200:
+ error_text = await response.text()
+ logger.error(
+ f"[NVIDIA Embedding] API Error: {response.status} - {error_text}",
+ )
+ raise Exception(
+ f"NVIDIA Embedding API request failed: HTTP {response.status} - {error_text}",
+ )
+
+ response_data = await response.json()
+ embeddings = self._parse_response(response_data)
+
+ usage = response_data.get("usage", {})
+ total_tokens = usage.get("total_tokens", 0)
+ if total_tokens > 0:
+ logger.debug(f"[NVIDIA Embedding] Token usage: {total_tokens}")
+
+ return embeddings
+
+ except aiohttp.ClientError as e:
+ logger.error(f"[NVIDIA Embedding] Network error: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"[NVIDIA Embedding] Error: {e}", exc_info=True)
+ raise
+
+ def get_dim(self) -> int:
+ if "embedding_dimensions" in self.provider_config:
+ try:
+ return int(self.provider_config["embedding_dimensions"])
+ except (ValueError, TypeError):
+ logger.warning(
+ f"embedding_dimensions in embedding configs is not a valid integer: "
+ f"'{self.provider_config['embedding_dimensions']}', ignored.",
+ )
+ return 0
+
+ async def terminate(self):
+ if self.client and not self.client.closed:
+ await self.client.close()
+ self.client = None
diff --git a/astrbot/core/provider/sources/ollama_embedding_source.py b/astrbot/core/provider/sources/ollama_embedding_source.py
new file mode 100644
index 000000000..b1842ef5e
--- /dev/null
+++ b/astrbot/core/provider/sources/ollama_embedding_source.py
@@ -0,0 +1,122 @@
+import aiohttp
+
+from astrbot import logger
+
+from ..entities import ProviderType
+from ..provider import EmbeddingProvider
+from ..register import register_provider_adapter
+
+
+@register_provider_adapter(
+ "ollama_embedding",
+ "Ollama Embedding 提供商适配器",
+ provider_type=ProviderType.EMBEDDING,
+)
+class OllamaEmbeddingProvider(EmbeddingProvider):
+ def __init__(self, provider_config: dict, provider_settings: dict) -> None:
+ super().__init__(provider_config, provider_settings)
+ self.provider_config = provider_config
+ self.provider_settings = provider_settings
+
+ self.base_url = (
+ provider_config.get("embedding_api_base", "http://localhost:11434")
+ .rstrip("/")
+ .removesuffix("/api/embed")
+ )
+ self.timeout = int(provider_config.get("timeout", 60))
+ self.model = provider_config.get("embedding_model", "nomic-embed-text")
+
+ proxy = provider_config.get("proxy", "")
+ self.proxy = proxy
+ if proxy:
+ logger.info(f"[Ollama Embedding] Using proxy: {proxy}")
+
+ self.client = None
+ self.set_model(self.model)
+
+ async def _get_client(self):
+ if self.client is None or self.client.closed:
+ headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
+ self.client = aiohttp.ClientSession(
+ headers=headers,
+ timeout=timeout,
+ )
+ return self.client
+
+ def _build_payload(self, text: list[str]) -> dict:
+ payload = {
+ "model": self.model,
+ "input": text,
+ }
+ if "embedding_dimensions" in self.provider_config:
+ try:
+ dimensions = int(self.provider_config["embedding_dimensions"])
+ if dimensions > 0:
+ payload["dimensions"] = dimensions
+ except (ValueError, TypeError):
+ pass
+ return payload
+
+ async def get_embedding(self, text: str) -> list[float]:
+ embeddings = await self.get_embeddings([text])
+ return embeddings[0] if embeddings else []
+
+ async def get_embeddings(self, text: list[str]) -> list[list[float]]:
+ client = await self._get_client()
+ if not client or client.closed:
+ raise Exception("[Ollama Embedding] Client session not initialized")
+
+ payload = self._build_payload(text)
+ request_url = f"{self.base_url}/api/embed"
+
+ try:
+ async with client.post(
+ request_url,
+ json=payload,
+ proxy=self.proxy or None,
+ ) as response:
+ if response.status != 200:
+ error_text = await response.text()
+ logger.error(
+ f"[Ollama Embedding] API Error: {response.status} - {error_text}",
+ )
+ raise Exception(
+ f"Ollama Embedding API request failed: HTTP {response.status} - {error_text}",
+ )
+
+ response_data = await response.json()
+ embeddings = response_data.get("embeddings", [])
+
+ if not embeddings:
+ raise Exception(
+ f"[Ollama Embedding] No embeddings returned: {response_data}",
+ )
+
+ return embeddings
+
+ except aiohttp.ClientError as e:
+ logger.error(f"[Ollama Embedding] Network error: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"[Ollama Embedding] Error: {e}", exc_info=True)
+ raise
+
+ def get_dim(self) -> int:
+ if "embedding_dimensions" in self.provider_config:
+ try:
+ return int(self.provider_config["embedding_dimensions"])
+ except (ValueError, TypeError):
+ logger.warning(
+ f"embedding_dimensions in embedding configs is not a valid integer: "
+ f"'{self.provider_config['embedding_dimensions']}', ignored.",
+ )
+ return 0
+
+ async def terminate(self):
+ if self.client and not self.client.closed:
+ await self.client.close()
+ self.client = None
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index 5ba40bd76..312ae962b 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -551,7 +551,7 @@ class ProviderOpenAIOfficial(Provider):
if not isinstance(msg, dict):
cleaned.append(msg)
continue
- msg = cast(dict[str, Any], msg)
+ msg = cast("dict[str, Any]", msg)
if msg.get("role") != "assistant":
cleaned.append(msg)
continue
@@ -913,6 +913,9 @@ class ProviderOpenAIOfficial(Provider):
args = {}
else:
args = func.arguments
+ # Some API may return None for tools with no parameters
+ if args is None:
+ args = {}
args_ls.append(args)
func_name_ls.append(func.name)
tool_call_ids.append(tool_call.id)
@@ -1026,7 +1029,7 @@ class ProviderOpenAIOfficial(Provider):
if part.get("type") == "think":
reasoning_content_present = True
reasoning_content = (reasoning_content or "") + str(
- part.get("think")
+ part.get("think"),
)
else:
new_content.append(part)
diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py
index 1c3c6cc82..e6476b266 100644
--- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py
+++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py
@@ -56,7 +56,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
self.model = await asyncio.get_running_loop().run_in_executor(
None,
lambda: cast(
- SenseVoiceModel,
+ "SenseVoiceModel",
SenseVoiceSmall(self.model_name, quantize=True, batch_size=16),
),
)
diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py
index a44b3ce53..a14563cfe 100644
--- a/astrbot/core/skills/skill_manager.py
+++ b/astrbot/core/skills/skill_manager.py
@@ -345,7 +345,7 @@ class SkillManager:
data_path = Path(get_astrbot_data_path())
self.config_path = str(data_path / SKILLS_CONFIG_FILENAME)
self.sandbox_skills_cache_path = str(
- data_path / SANDBOX_SKILLS_CACHE_FILENAME
+ data_path / SANDBOX_SKILLS_CACHE_FILENAME,
)
os.makedirs(self.skills_root, exist_ok=True)
@@ -441,7 +441,8 @@ class SkillManager:
continue
description = str(item.get("description", "") or "")
path = _normalize_cached_sandbox_skill_path(
- name, str(item.get("path", "") or "")
+ name,
+ str(item.get("path", "") or ""),
)
deduped[name] = {
"name": name,
@@ -491,7 +492,8 @@ class SkillManager:
continue
name = str(item.get("name", "") or "").strip()
path = _normalize_cached_sandbox_skill_path(
- name, str(item.get("path", "") or "")
+ name,
+ str(item.get("path", "") or ""),
)
if not name or not _SKILL_NAME_RE.match(name):
continue
@@ -534,7 +536,7 @@ class SkillManager:
source_label = "synced" if sandbox_exists else "local"
if runtime == "sandbox" and show_sandbox_path:
path_str = sandbox_cached_paths.get(
- skill_name
+ skill_name,
) or _default_sandbox_skill_path(skill_name)
else:
path_str = str(skill_md)
@@ -575,7 +577,7 @@ class SkillManager:
)
if runtime == "sandbox" and show_sandbox_path:
path_str = sandbox_cached_paths.get(
- skill_name
+ skill_name,
) or _default_sandbox_skill_path(skill_name)
else:
path_str = str(skill_md)
@@ -615,7 +617,7 @@ class SkillManager:
# since there is no local path to show. Always prefer the
# actual path from sandbox cache.
path_str = sandbox_cached_paths.get(
- skill_name
+ skill_name,
) or _default_sandbox_skill_path(skill_name)
skills_by_name[skill_name] = SkillInfo(
name=skill_name,
@@ -656,7 +658,7 @@ class SkillManager:
def set_skill_active(self, name: str, active: bool) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
- "Sandbox preset skill cannot be enabled/disabled from local skill management."
+ "Sandbox preset skill cannot be enabled/disabled from local skill management.",
)
config = self._load_config()
config.setdefault("skills", {})
@@ -684,11 +686,11 @@ class SkillManager:
def delete_skill(self, name: str) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
- "Sandbox preset skill cannot be deleted from local skill management."
+ "Sandbox preset skill cannot be deleted from local skill management.",
)
if self.is_plugin_skill(name):
raise PermissionError(
- "Plugin-provided skill cannot be deleted from local skill management."
+ "Plugin-provided skill cannot be deleted from local skill management.",
)
skill_dir = Path(self.skills_root) / name
@@ -740,7 +742,7 @@ class SkillManager:
if skill_name_hint is not None:
archive_skill_name = _normalize_skill_name(skill_name_hint)
if archive_skill_name and not _SKILL_NAME_RE.fullmatch(
- archive_skill_name
+ archive_skill_name,
):
raise ValueError("Invalid skill name.")
@@ -765,7 +767,7 @@ class SkillManager:
candidate_name = _normalize_skill_name(src_dir_name)
if not candidate_name or not _SKILL_NAME_RE.fullmatch(
- candidate_name
+ candidate_name,
):
continue
@@ -782,7 +784,7 @@ class SkillManager:
raise FileExistsError(
"One or more skills from the archive already exist and "
"overwrite=False. No skills were installed. Conflicting "
- f"paths: {', '.join(conflict_dirs)}"
+ f"paths: {', '.join(conflict_dirs)}",
)
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
@@ -794,7 +796,7 @@ class SkillManager:
if root_mode:
archive_hint = _normalize_skill_name(
- archive_skill_name or zip_path_obj.stem
+ archive_skill_name or zip_path_obj.stem,
)
if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint):
raise ValueError("Invalid skill name.")
@@ -804,7 +806,7 @@ class SkillManager:
normalized_path = _normalize_skill_markdown_path(src_dir)
if normalized_path is None:
raise ValueError(
- "SKILL.md not found in the root of the zip archive."
+ "SKILL.md not found in the root of the zip archive.",
)
dest_dir = Path(self.skills_root) / skill_name
@@ -824,7 +826,7 @@ class SkillManager:
for archive_root_name in top_dirs:
archive_root_name_normalized = _normalize_skill_name(
- archive_root_name
+ archive_root_name,
)
if (
@@ -852,7 +854,7 @@ class SkillManager:
if dest_dir.exists():
if not overwrite:
raise FileExistsError(
- f"Skill {skill_name} already exists."
+ f"Skill {skill_name} already exists.",
)
shutil.rmtree(dest_dir)
@@ -862,7 +864,7 @@ class SkillManager:
if not installed_skills:
raise ValueError(
- "No valid SKILL.md found in any folder of the zip archive."
+ "No valid SKILL.md found in any folder of the zip archive.",
)
return ", ".join(installed_skills)
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index cf18610cf..00fa4a374 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -134,6 +134,7 @@ class Context:
Raises:
ChatProviderNotFoundError: If the specified chat provider ID is not found
Exception: For other errors during LLM generation
+
"""
prov = await self.provider_manager.get_provider_by_id(chat_provider_id)
if not prov or not isinstance(prov, Provider):
@@ -191,6 +192,7 @@ class Context:
Raises:
ChatProviderNotFoundError: If the specified chat provider ID is not found
Exception: For other errors during LLM generation
+
"""
# Import here to avoid circular imports
from astrbot.core.astr_agent_context import (
@@ -238,10 +240,12 @@ class Context:
}
if request.func_tool and request.func_tool.get_tool("astrbot_file_read_tool"):
other_kwargs.setdefault(
- "tool_result_overflow_dir", get_astrbot_system_tmp_path()
+ "tool_result_overflow_dir",
+ get_astrbot_system_tmp_path(),
)
other_kwargs.setdefault(
- "read_tool", request.func_tool.get_tool("astrbot_file_read_tool")
+ "read_tool",
+ request.func_tool.get_tool("astrbot_file_read_tool"),
)
await agent_runner.reset(
@@ -274,6 +278,7 @@ class Context:
Raises:
ProviderNotFoundError: 未找到。
+
"""
prov = self.get_using_provider(umo)
if not prov:
@@ -305,6 +310,7 @@ class Context:
Note:
注册的工具默认是激活状态。
+
"""
return self.provider_manager.llm_tools.activate_llm_tool(name, star_map)
@@ -316,6 +322,7 @@ class Context:
Returns:
如果成功停用返回 True,如果没找到工具返回 False。
+
"""
return self.provider_manager.llm_tools.deactivate_llm_tool(name)
@@ -335,11 +342,12 @@ class Context:
Note:
如果提供者 ID 存在但未找到提供者,会记录警告日志。
+
"""
prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
- f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
+ f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。",
)
return prov
@@ -371,6 +379,7 @@ class Context:
Raises:
ValueError: 该会话来源配置的的对话模型(提供商)的类型不正确。
+
"""
prov = self.provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION,
@@ -380,7 +389,7 @@ class Context:
return None
if not isinstance(prov, Provider):
raise ValueError(
- f"该会话来源的对话模型(提供商)的类型不正确: {type(prov)}"
+ f"该会话来源的对话模型(提供商)的类型不正确: {type(prov)}",
)
return prov
@@ -395,6 +404,7 @@ class Context:
Raises:
ValueError: 返回的提供者不是 TTSProvider 类型。
+
"""
prov = self.provider_manager.get_using_provider(
provider_type=ProviderType.TEXT_TO_SPEECH,
@@ -415,6 +425,7 @@ class Context:
Raises:
ValueError: 返回的提供者不是 STTProvider 类型。
+
"""
prov = self.provider_manager.get_using_provider(
provider_type=ProviderType.SPEECH_TO_TEXT,
@@ -435,6 +446,7 @@ class Context:
Note:
如果不提供 umo 参数,将返回默认配置。
+
"""
if not umo:
# 使用默认配置
@@ -461,6 +473,7 @@ class Context:
Note:
当 session 为字符串时,会尝试解析为 MessageSession 对象。(类名为MessageSesion是因为历史遗留拼写错误)
qq_official(QQ 官方 API 平台) 不支持此方法。
+
"""
if isinstance(session, str):
try:
@@ -473,7 +486,7 @@ class Context:
await platform.send_by_session(session, message_chain)
return True
logger.warning(
- f"cannot find platform for session {str(session)}, message not sent"
+ f"cannot find platform for session {session!s}, message not sent",
)
return False
@@ -485,6 +498,7 @@ class Context:
Note:
如果工具已存在,会替换已存在的工具。
+
"""
tool_name = {tool.name for tool in self.provider_manager.llm_tools.func_list}
module_path = ""
@@ -504,7 +518,7 @@ class Context:
else:
tool.handler_module_path = module_path
logger.info(
- f"plugin(module_path {module_path}) added LLM tool: {tool.name}"
+ f"plugin(module_path {module_path}) added LLM tool: {tool.name}",
)
if tool.name in tool_name:
@@ -529,6 +543,7 @@ class Context:
Note:
如果相同路由和方法已注册,会替换现有的 API。
+
"""
for idx, api in enumerate(self._registered_web_apis):
if api[0] == route and methods == api[2]:
@@ -556,6 +571,7 @@ class Context:
Note:
该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0)
+
"""
for platform in self.platform_manager.platform_insts:
name = platform.meta().name
@@ -579,6 +595,7 @@ class Context:
Note:
可以通过 event.get_platform_id() 获取平台 ID。
+
"""
for platform in self.platform_manager.platform_insts:
if platform.meta().id == platform_id:
@@ -589,6 +606,7 @@ class Context:
Returns:
数据库实例。
+
"""
return self._db
@@ -597,6 +615,7 @@ class Context:
Args:
provider: 提供者实例。
+
"""
self.provider_manager.provider_insts.append(provider)
@@ -619,6 +638,7 @@ class Context:
Note:
异步处理函数会接收到额外的关键词参数:event: AstrMessageEvent, context: Context。
该方法已弃用,请使用新的注册方式。
+
"""
md = StarHandlerMetadata(
event_type=EventType.OnLLMRequestEvent,
@@ -641,6 +661,7 @@ class Context:
Note:
如果再要启用,需要重新注册。
该方法已弃用。
+
"""
self.provider_manager.llm_tools.remove_func(name)
@@ -667,6 +688,7 @@ class Context:
Note:
推荐使用装饰器注册指令。该方法将在未来的版本中被移除。
+
"""
md = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
@@ -694,5 +716,6 @@ class Context:
Note:
该方法已弃用。
+
"""
self._register_tasks.append(task)
diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py
index dfab95025..30825e404 100644
--- a/astrbot/core/star/register/star_handler.py
+++ b/astrbot/core/star/register/star_handler.py
@@ -283,7 +283,11 @@ def register_platform_adapter_type(
"""注册一个 PlatformAdapterType"""
def decorator(awaitable):
- handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
+ handler_md = get_handler_or_create(
+ awaitable,
+ EventType.AdapterMessageEvent,
+ **kwargs,
+ )
handler_md.event_filters.append(
PlatformAdapterTypeFilter(platform_adapter_type),
)
@@ -307,7 +311,11 @@ def register_regex(regex: str | re.Pattern, **kwargs):
return decorator
-def register_permission_type(permission_type: PermissionType, raise_error: bool = True):
+def register_permission_type(
+ permission_type: PermissionType,
+ raise_error: bool = True,
+ **kwargs,
+):
"""注册一个 PermissionType
Args:
@@ -317,7 +325,11 @@ def register_permission_type(permission_type: PermissionType, raise_error: bool
"""
def decorator(awaitable):
- handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
+ handler_md = get_handler_or_create(
+ awaitable,
+ EventType.AdapterMessageEvent,
+ **kwargs,
+ )
handler_md.event_filters.append(
PermissionTypeFilter(permission_type, raise_error),
)
@@ -432,11 +444,11 @@ def register_on_llm_request(**kwargs):
from astrbot.api.provider import ProviderRequest
@on_llm_request()
- async def test(self, event: AstrMessageEvent, request: ProviderRequest) -> None:
- request.system_prompt += "你是一个猫娘..."
+ async def test(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
+ req.system_prompt += "你是一个猫娘..."
```
- 请务必接收两个参数:event, request
+ 请务必接收两个参数:event, req
"""
diff --git a/astrbot/core/star/session_plugin_manager.py b/astrbot/core/star/session_plugin_manager.py
index ef10b6f89..c1f2168c2 100644
--- a/astrbot/core/star/session_plugin_manager.py
+++ b/astrbot/core/star/session_plugin_manager.py
@@ -18,7 +18,7 @@ def _normalize_session_plugin_config(value: object) -> dict[str, dict[str, list[
for session_id, raw_settings in value.items():
if not isinstance(session_id, str) or not isinstance(raw_settings, dict):
continue
- raw_settings = cast(dict[str, Any], raw_settings)
+ raw_settings = cast("dict[str, Any]", raw_settings)
settings: dict[str, list[str]] = {}
enabled_plugins = raw_settings.get("enabled_plugins")
if isinstance(enabled_plugins, list) and all(
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index 41f283cef..0ea6d19bb 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -139,7 +139,7 @@ async def _install_requirements_with_precheck(
if install_plan is None:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
- f"{requirements_path}"
+ f"{requirements_path}",
)
await pip_installer.install(requirements_path=requirements_path)
return
@@ -164,7 +164,7 @@ async def _install_requirements_with_precheck(
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
- f"{requirements_path} -> {sorted(install_plan.missing_names)}"
+ f"{requirements_path} -> {sorted(install_plan.missing_names)}",
)
with _temporary_filtered_requirements_file(
@@ -190,7 +190,9 @@ class PluginManager:
self.plugin_config_path = get_astrbot_config_path()
"""存储插件配置的路径。data/config"""
self.reserved_plugin_path = os.path.join(
- get_astrbot_path(), "astrbot", "builtin_stars"
+ get_astrbot_path(),
+ "astrbot",
+ "builtin_stars",
)
"""保留插件的路径。在 astrbot/builtin_stars 目录下"""
self.conf_schema_fname = "_conf_schema.json"
@@ -308,7 +310,8 @@ class PluginManager:
return plugins
async def _check_plugin_dept_update(
- self, target_plugin: str | None = None
+ self,
+ target_plugin: str | None = None,
) -> bool | None:
"""检查插件的依赖
如果 target_plugin 为 None,则检查所有插件的依赖
@@ -367,7 +370,7 @@ class PluginManager:
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
return ImportDependencyRecoveryState(
- ImportDependencyRecoveryMode.RECOVER_ON_FAILURE
+ ImportDependencyRecoveryMode.RECOVER_ON_FAILURE,
)
if install_plan.version_mismatch_names:
return ImportDependencyRecoveryState(
@@ -390,19 +393,19 @@ class PluginManager:
) -> ModuleType | None:
try:
logger.info(
- f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
+ f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}",
)
pip_installer.prefer_installed_dependencies(
- requirements_path=requirements_path
+ requirements_path=requirements_path,
)
module = __import__(path, fromlist=[module_str])
logger.info(
- f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
+ f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。",
)
return module
except (ImportError, ModuleNotFoundError) as recover_exc:
logger.info(
- f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
+ f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}",
)
return None
@@ -423,11 +426,11 @@ class PluginManager:
if recovery_state.mode is ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER:
try:
pip_installer.prefer_installed_dependencies(
- requirements_path=requirements_path
+ requirements_path=requirements_path,
)
except Exception as preload_exc:
logger.info(
- f"插件 {root_dir_name} 预加载已安装依赖失败,将继续常规导入: {preload_exc!s}"
+ f"插件 {root_dir_name} 预加载已安装依赖失败,将继续常规导入: {preload_exc!s}",
)
try:
@@ -573,11 +576,11 @@ class PluginManager:
def _validate_importable_name(plugin_name: str) -> None:
if "/" in plugin_name or "\\" in plugin_name:
raise ValueError(
- "metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。"
+ "metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。",
)
if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name):
raise Exception(
- "metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。"
+ "metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。",
)
@staticmethod
@@ -750,7 +753,7 @@ class PluginManager:
"display_name": metadata.display_name,
"support_platforms": metadata.support_platforms,
"astrbot_version": metadata.astrbot_version,
- }
+ },
)
except Exception as metadata_error:
logger.debug(
@@ -785,8 +788,7 @@ class PluginManager:
self.failed_plugin_info = "\n".join(lines) + "\n"
async def reload_failed_plugin(self, dir_name):
- """
- 重新加载未注册(加载失败)的插件
+ """重新加载未注册(加载失败)的插件
Args:
dir_name (str): 要重载的特定插件名称。
Returns:
@@ -794,7 +796,6 @@ class PluginManager:
- success (bool): 重载是否成功
- error_message (str|None): 错误信息,成功时为 None
"""
-
async with self._pm_lock:
if dir_name not in self.failed_plugin_dict:
return False, "插件不存在于失败列表中"
@@ -809,8 +810,7 @@ class PluginManager:
self.failed_plugin_dict.pop(dir_name, None)
self._rebuild_failed_plugin_info()
return success, None
- else:
- return False, error
+ return False, error
async def reload(self, specified_plugin_name=None):
"""重新加载插件
@@ -1008,7 +1008,7 @@ class PluginManager:
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
- or "The plugin is not compatible with the current AstrBot version."
+ or "The plugin is not compatible with the current AstrBot version.",
)
logger.info(metadata)
@@ -1131,7 +1131,7 @@ class PluginManager:
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
- or "The plugin is not compatible with the current AstrBot version."
+ or "The plugin is not compatible with the current AstrBot version.",
)
metadata.star_cls = obj
@@ -1397,7 +1397,7 @@ class PluginManager:
plugin_path = os.path.join(self.plugin_store_path, repo_name)
if os.path.exists(plugin_path):
raise Exception(
- f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。"
+ f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。",
)
if download_url:
plugin_path = await self.updator.install(
@@ -1416,7 +1416,7 @@ class PluginManager:
metadata_dir_name,
)
if target_plugin_path != plugin_path and os.path.exists(
- target_plugin_path
+ target_plugin_path,
):
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != plugin_path:
@@ -1434,7 +1434,7 @@ class PluginManager:
if not success:
raise Exception(
error_message
- or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
+ or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。",
)
# Get the plugin metadata to return repo info
@@ -1642,7 +1642,7 @@ class PluginManager:
module_prefix = ".".join(plugin_module_path.split(".")[:-1])
if module_prefix:
unregistered_adapters = unregister_platform_adapters_by_module(
- module_prefix
+ module_prefix,
)
for adapter_name in unregistered_adapters:
logger.info(
@@ -1658,7 +1658,10 @@ class PluginManager:
)
async def update_plugin(
- self, plugin_name: str, proxy="", download_url: str = ""
+ self,
+ plugin_name: str,
+ proxy="",
+ download_url: str = "",
) -> None:
"""升级一个插件"""
plugin = self.context.get_registered_star(plugin_name)
@@ -1791,13 +1794,17 @@ class PluginManager:
await self.reload(plugin_name)
async def install_plugin_from_file(
- self, zip_file_path: str, ignore_version_check: bool = False
+ self,
+ zip_file_path: str,
+ ignore_version_check: bool = False,
):
dir_name = os.path.splitext(os.path.basename(zip_file_path))[0]
desti_dir = tempfile.mkdtemp(
- dir=self.plugin_store_path, prefix="plugin_upload_"
+ dir=self.plugin_store_path,
+ prefix="plugin_upload_",
)
temp_desti_dir = desti_dir
+ skip_failed_tracking = False
try:
self.updator.unzip_file(zip_file_path, desti_dir)
@@ -1807,6 +1814,7 @@ class PluginManager:
metadata_dir_name,
)
if target_plugin_path != desti_dir and os.path.exists(target_plugin_path):
+ skip_failed_tracking = True
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != desti_dir:
os.rename(desti_dir, target_plugin_path)
@@ -1827,7 +1835,7 @@ class PluginManager:
if not success:
raise Exception(
error_message
- or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
+ or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。",
)
# Get the plugin metadata to return repo info
@@ -1870,17 +1878,20 @@ class PluginManager:
return plugin_info
except Exception as e:
- self._track_failed_install_dir(
- dir_name=dir_name,
- plugin_path=desti_dir,
- error=e,
- )
+ if not skip_failed_tracking:
+ self._track_failed_install_dir(
+ dir_name=dir_name,
+ plugin_path=desti_dir,
+ error=e,
+ )
logger.warning(
f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}",
)
raise
finally:
- if temp_desti_dir != desti_dir and os.path.isdir(temp_desti_dir):
+ if (skip_failed_tracking or temp_desti_dir != desti_dir) and os.path.isdir(
+ temp_desti_dir,
+ ):
try:
remove_dir(temp_desti_dir)
except Exception as e:
diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py
index 3663f6a5a..7fe16b234 100644
--- a/astrbot/core/star/updator.py
+++ b/astrbot/core/star/updator.py
@@ -31,18 +31,21 @@ class PluginUpdator(RepoZipUpdator):
return plugin_path
async def update(
- self, plugin: StarMetadata, proxy="", download_url: str = ""
+ self,
+ plugin: StarMetadata,
+ proxy="",
+ download_url: str = "",
) -> str:
repo_url = plugin.repo
if not repo_url and not download_url:
raise Exception(
- f"Plugin {plugin.name} does not specify a repository URL or download URL."
+ f"Plugin {plugin.name} does not specify a repository URL or download URL.",
)
if not plugin.root_dir_name:
raise Exception(
- f"Plugin {plugin.name} does not specify a root directory name."
+ f"Plugin {plugin.name} does not specify a root directory name.",
)
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
@@ -52,7 +55,7 @@ class PluginUpdator(RepoZipUpdator):
)
if download_url:
logger.info(
- f"Downloading plugin update archive for {plugin.name}: {download_url}"
+ f"Downloading plugin update archive for {plugin.name}: {download_url}",
)
await self._download_file(download_url, plugin_path + ".zip")
else:
diff --git a/astrbot/core/tools/computer_tools/cua.py b/astrbot/core/tools/computer_tools/cua.py
index c6ca2a2d5..cddd29dbd 100644
--- a/astrbot/core/tools/computer_tools/cua.py
+++ b/astrbot/core/tools/computer_tools/cua.py
@@ -42,7 +42,7 @@ async def _get_gui_component(context: ContextWrapper[AstrAgentContext]) -> Any:
if gui is None:
raise RuntimeError(
"Current sandbox booter does not support CUA GUI capability. "
- "Please switch sandbox booter to cua."
+ "Please switch sandbox booter to cua.",
)
return gui
@@ -69,7 +69,7 @@ class CuaScreenshotTool(FunctionTool):
"default": True,
},
},
- }
+ },
)
async def call(
@@ -91,7 +91,7 @@ class CuaScreenshotTool(FunctionTool):
payload["sent_to_user"] = True
image_data = payload.pop("base64", "")
content: list[ContentBlock] = [
- mcp.types.TextContent(type="text", text=_to_json(payload))
+ mcp.types.TextContent(type="text", text=_to_json(payload)),
]
if return_image_to_llm:
content.append(
@@ -99,7 +99,7 @@ class CuaScreenshotTool(FunctionTool):
type="image",
data=str(image_data),
mimeType=str(payload.get("mime_type", "image/png")),
- )
+ ),
)
return mcp.types.CallToolResult(content=content)
except Exception as e:
@@ -124,7 +124,7 @@ class CuaMouseClickTool(FunctionTool):
},
},
"required": ["x", "y"],
- }
+ },
)
async def call(
@@ -156,7 +156,7 @@ class CuaKeyboardTypeTool(FunctionTool):
"text": {"type": "string", "description": "Text to type."},
},
"required": ["text"],
- }
+ },
)
async def call(
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index 3f32f59ba..a68cd4b36 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -83,7 +83,7 @@ def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> lis
f"data/workspaces/{normalized_umo}",
get_astrbot_system_tmp_path(),
get_astrbot_temp_path(),
- ]
+ ],
)
return labels
@@ -195,12 +195,12 @@ def _normalize_rw_path(
allowed_roots=allowed_roots,
):
allowed = ", ".join(
- _restricted_env_path_labels(umo, include_plugin_skills=not write)
+ _restricted_env_path_labels(umo, include_plugin_skills=not write),
)
access = "Write" if write else "Read"
raise PermissionError(
f"{access} access is restricted for this user. "
- f"Allowed directories: {allowed}. Blocked path: {normalized_path}."
+ f"Allowed directories: {allowed}. Blocked path: {normalized_path}.",
)
return normalized_path
@@ -589,7 +589,7 @@ class GrepTool(FunctionTool):
]
if disallowed:
allowed = ", ".join(
- _restricted_env_path_labels(umo, include_plugin_skills=True)
+ _restricted_env_path_labels(umo, include_plugin_skills=True),
)
blocked = ", ".join(disallowed)
raise PermissionError(
@@ -793,7 +793,7 @@ class FileDownloadTool(FunctionTool):
message_component = File(name=name, file=local_path)
sent_as = "file"
await context.context.event.send(
- MessageChain(chain=[message_component])
+ MessageChain(chain=[message_component]),
)
except Exception as e:
logger.error(f"Error sending file message: {e}")
diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py
index 0604ff54a..60890c730 100644
--- a/astrbot/core/tools/message_tools.py
+++ b/astrbot/core/tools/message_tools.py
@@ -113,7 +113,10 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
self,
context: ContextWrapper[AstrAgentContext],
path: str,
+ *,
+ component_type: str = "file",
) -> tuple[str, bool]:
+ path = str(path)
# if the path is relative, check if the file exists in user's local workspace
if not os.path.isabs(path):
unified_msg_origin = context.context.event.unified_msg_origin
@@ -149,8 +152,9 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
return local_path, True
except Exception as exc:
logger.warning(f"Failed to check/download file from sandbox: {exc}")
+ raise
- return path, False
+ raise FileNotFoundError(f"{component_type} path does not exist: {path}")
async def call(
self,
@@ -164,7 +168,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
session = kwargs.get("session") or current_session
if session != current_session:
if permission_error := check_admin_permission(
- context, "Send message to another session"
+ context,
+ "Send message to another session",
):
return permission_error
messages = kwargs.get("messages")
@@ -194,6 +199,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
local_path, _ = await self._resolve_path_from_sandbox(
context,
path,
+ component_type="image",
)
components.append(Comp.Image.fromFileSystem(path=local_path))
elif url:
@@ -207,6 +213,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
local_path, _ = await self._resolve_path_from_sandbox(
context,
path,
+ component_type="record",
)
components.append(Comp.Record.fromFileSystem(path=local_path))
elif url:
@@ -220,6 +227,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
local_path, _ = await self._resolve_path_from_sandbox(
context,
path,
+ component_type="video",
)
components.append(Comp.Video.fromFileSystem(path=local_path))
elif url:
@@ -239,6 +247,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
local_path, _ = await self._resolve_path_from_sandbox(
context,
path,
+ component_type="file",
)
components.append(Comp.File(name=name, file=local_path))
elif url:
@@ -254,6 +263,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
return (
f"error: unsupported message type '{msg_type}' at index {idx}."
)
+ except FileNotFoundError as exc:
+ return f"error: {exc}"
except Exception as exc:
return f"error: failed to build messages[{idx}] component: {exc}"
diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py
index e9d4fedba..fd462c260 100644
--- a/astrbot/core/tools/web_search_tools.py
+++ b/astrbot/core/tools/web_search_tools.py
@@ -283,35 +283,37 @@ async def _firecrawl_search(
"Authorization": f"Bearer {firecrawl_key}",
"Content-Type": "application/json",
}
- async with aiohttp.ClientSession(trust_env=True) as session:
- async with session.post(
+ async with (
+ aiohttp.ClientSession(trust_env=True) as session,
+ session.post(
"https://api.firecrawl.dev/v2/search",
json=payload,
headers=header,
- ) as response:
- if response.status != 200:
- reason = await response.text()
- raise Exception(
- f"Firecrawl web search failed: {reason}, status: {response.status}",
- )
- data = await response.json()
- rows = data.get("data", [])
- if isinstance(rows, dict):
- rows = rows.get("web", [])
- return [
- SearchResult(
- title=item.get("title", ""),
- url=item.get("url", ""),
- snippet=(
- item.get("description")
- or item.get("snippet")
- or item.get("markdown")
- or ""
- ),
- )
- for item in rows
- if item.get("url")
- ]
+ ) as response,
+ ):
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Firecrawl web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ rows = data.get("data", [])
+ if isinstance(rows, dict):
+ rows = rows.get("web", [])
+ return [
+ SearchResult(
+ title=item.get("title", ""),
+ url=item.get("url", ""),
+ snippet=(
+ item.get("description")
+ or item.get("snippet")
+ or item.get("markdown")
+ or ""
+ ),
+ )
+ for item in rows
+ if item.get("url")
+ ]
async def _firecrawl_scrape(provider_settings: dict, payload: dict) -> dict:
@@ -320,24 +322,26 @@ async def _firecrawl_scrape(provider_settings: dict, payload: dict) -> dict:
"Authorization": f"Bearer {firecrawl_key}",
"Content-Type": "application/json",
}
- async with aiohttp.ClientSession(trust_env=True) as session:
- async with session.post(
+ async with (
+ aiohttp.ClientSession(trust_env=True) as session,
+ session.post(
"https://api.firecrawl.dev/v2/scrape",
json=payload,
headers=header,
- ) as response:
- if response.status != 200:
- reason = await response.text()
- raise Exception(
- f"Firecrawl web scraper failed: {reason}, status: {response.status}",
- )
- data = await response.json()
- result = data.get("data", {})
- if not result:
- raise ValueError(
- "Error: Firecrawl web scraper does not return any results."
- )
- return result
+ ) as response,
+ ):
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Firecrawl web scraper failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ result = data.get("data", {})
+ if not result:
+ raise ValueError(
+ "Error: Firecrawl web scraper does not return any results.",
+ )
+ return result
async def _baidu_search(
@@ -661,7 +665,7 @@ class FirecrawlWebSearchTool(FunctionTool[AstrAgentContext]):
},
},
"required": ["query"],
- }
+ },
)
async def call(self, context, **kwargs) -> ToolExecResult:
@@ -715,7 +719,7 @@ class FirecrawlExtractWebPageTool(FunctionTool[AstrAgentContext]):
},
},
"required": ["url"],
- }
+ },
)
async def call(self, context, **kwargs) -> ToolExecResult:
diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py
index c42ed9f13..52327ecdd 100644
--- a/astrbot/core/updator.py
+++ b/astrbot/core/updator.py
@@ -143,12 +143,13 @@ class AstrBotUpdator(RepoZipUpdator):
async def get_releases(self) -> list:
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
- async def update( # type: ignore[override]
+ async def update(
self,
reboot=False,
latest=True,
version=None,
proxy="",
+ progress_callback=None,
) -> None:
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
@@ -181,7 +182,11 @@ class AstrBotUpdator(RepoZipUpdator):
file_url = f"{proxy}/{file_url}"
try:
- await self._download_file(file_url, "temp.zip")
+ await self._download_file(
+ file_url,
+ "temp.zip",
+ progress_callback=progress_callback,
+ )
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file("temp.zip", self.MAIN_PATH)
except BaseException as e:
diff --git a/astrbot/core/utils/auth_password.py b/astrbot/core/utils/auth_password.py
index db5a686c2..c80317165 100644
--- a/astrbot/core/utils/auth_password.py
+++ b/astrbot/core/utils/auth_password.py
@@ -4,6 +4,7 @@ import hashlib
import hmac
import re
import secrets
+import string
from typing import Any
try:
@@ -20,9 +21,26 @@ _PBKDF2_ALGORITHM = "pbkdf2_sha256"
_PBKDF2_FORMAT = f"{_PBKDF2_ALGORITHM}$"
_LEGACY_MD5_LENGTH = 32
_DASHBOARD_PASSWORD_MIN_LENGTH = 12
+_GENERATED_DASHBOARD_PASSWORD_LENGTH = 24
DEFAULT_DASHBOARD_PASSWORD = "astrbot"
+def generate_dashboard_password() -> str:
+ """Generate a strong dashboard password that satisfies the complexity policy."""
+ alphabet = string.ascii_letters + string.digits
+ password_chars = [
+ secrets.choice(string.ascii_uppercase),
+ secrets.choice(string.ascii_lowercase),
+ secrets.choice(string.digits),
+ *(
+ secrets.choice(alphabet)
+ for _ in range(_GENERATED_DASHBOARD_PASSWORD_LENGTH - 3)
+ ),
+ ]
+ secrets.SystemRandom().shuffle(password_chars)
+ return "".join(password_chars)
+
+
def hash_dashboard_password(raw_password: str) -> str:
"""Return a salted hash for dashboard password using Argon2 (if available) or PBKDF2-HMAC-SHA256 fallback."""
if not isinstance(raw_password, str) or raw_password == "":
@@ -44,6 +62,13 @@ def hash_dashboard_password(raw_password: str) -> str:
return f"{_PBKDF2_FORMAT}{_PBKDF2_ITERATIONS}${salt}${digest}"
+def hash_legacy_dashboard_password(raw_password: str) -> str:
+ """Return legacy MD5 hash for downgrade compatibility only."""
+ if not isinstance(raw_password, str) or raw_password == "":
+ raise ValueError("Password cannot be empty")
+ return hashlib.md5(raw_password.encode("utf-8")).hexdigest()
+
+
def validate_dashboard_password(raw_password: str) -> None:
"""Validate whether dashboard password meets the minimal complexity policy."""
if not isinstance(raw_password, str) or raw_password == "":
diff --git a/astrbot/core/utils/core_constraints.py b/astrbot/core/utils/core_constraints.py
index 60a0ae71b..6d0b1c8a5 100644
--- a/astrbot/core/utils/core_constraints.py
+++ b/astrbot/core/utils/core_constraints.py
@@ -106,8 +106,8 @@ class CoreConstraintsProvider:
(
*_get_core_constraints(self._core_dist_name),
*get_desktop_core_lock_constraints(),
- )
- )
+ ),
+ ),
)
if not constraints:
yield None
@@ -151,8 +151,8 @@ class CoreConstraintsProvider:
(
*_get_core_constraints(self._core_dist_name),
*get_desktop_core_lock_constraints(),
- )
- )
+ ),
+ ),
)
if not constraints:
yield None
diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py
index fa622db26..af09841b8 100644
--- a/astrbot/core/utils/io.py
+++ b/astrbot/core/utils/io.py
@@ -1,7 +1,9 @@
import asyncio
import base64
+import inspect
import logging
import os
+import re
import shutil
import socket
import ssl
@@ -18,6 +20,7 @@ import psutil
from PIL import Image
from .astrbot_path import get_astrbot_data_path, get_astrbot_path, get_astrbot_temp_path
+from .version_comparator import VersionComparator
logger = logging.getLogger("astrbot")
@@ -157,7 +160,20 @@ async def download_image_by_url(
raise e
-async def download_file(url: str, path: str, show_progress: bool = False) -> None:
+async def _emit_download_progress(progress_callback, payload: dict) -> None:
+ if not progress_callback:
+ return
+ result = progress_callback(payload)
+ if inspect.isawaitable(result):
+ await result
+
+
+async def download_file(
+ url: str,
+ path: str,
+ show_progress: bool = False,
+ progress_callback=None,
+) -> None:
"""从指定 url 下载文件到指定路径 path"""
aiohttp = _get_aiohttp()
try:
@@ -175,13 +191,23 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
) as resp:
if resp.status != 200:
logger.error(
- f"Failed to download file from {url}. HTTP status code: {resp.status}"
+ f"Failed to download file from {url}. HTTP status code: {resp.status}",
)
total_size = int(resp.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
if show_progress:
print(f"Downloading: {url} | Size: {total_size / 1024:.2f} KB")
+ await _emit_download_progress(
+ progress_callback,
+ {
+ "url": url,
+ "downloaded": 0,
+ "total": total_size,
+ "percent": 0,
+ "speed": 0,
+ },
+ )
with open(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
@@ -189,22 +215,43 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
break
await f.write(chunk)
downloaded_size += len(chunk)
+ elapsed_time = (
+ time.time() - start_time
+ if time.time() - start_time > 0
+ else 1
+ )
+ speed = downloaded_size / 1024 / elapsed_time # KB/s
+ percent = downloaded_size / total_size if total_size > 0 else 0
+ await _emit_download_progress(
+ progress_callback,
+ {
+ "url": url,
+ "downloaded": downloaded_size,
+ "total": total_size,
+ "percent": percent,
+ "speed": speed,
+ },
+ )
if show_progress:
- elapsed_time = (
- time.time() - start_time
- if time.time() - start_time > 0
- else 1
- )
- speed = downloaded_size / 1024 / elapsed_time # KB/s
print(
- f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
+ f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
end="",
)
+ await _emit_download_progress(
+ progress_callback,
+ {
+ "url": url,
+ "downloaded": downloaded_size,
+ "total": total_size,
+ "percent": 1,
+ "speed": 0,
+ },
+ )
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证(仅在证书验证失败时作为fallback)
logger.warning(
f"SSL certificate verification failed for {url}. "
- "Falling back to unverified connection (CERT_NONE). "
+ "Falling back to unverified connection (CERT_NONE). ",
)
logger.warning(
f"SSL certificate verification failed for {url}. "
@@ -215,31 +262,66 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
- async with aiohttp.ClientSession() as session:
- async with session.get(
+ async with (
+ aiohttp.ClientSession() as session,
+ session.get(
url,
ssl=ssl_context,
timeout=aiohttp.ClientTimeout(total=120),
- ) as resp:
- total_size = int(resp.headers.get("content-length", 0))
- downloaded_size = 0
- start_time = time.time()
- if show_progress:
- print(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
- with open(path, "wb") as f:
- while True:
- chunk = await resp.content.read(8192)
- if not chunk:
- break
- await f.write(chunk)
- downloaded_size += len(chunk)
- if show_progress:
- elapsed_time = time.time() - start_time
- speed = downloaded_size / 1024 / elapsed_time # KB/s
- print(
- f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
- end="",
- )
+ ) as resp,
+ ):
+ total_size = int(resp.headers.get("content-length", 0))
+ downloaded_size = 0
+ start_time = time.time()
+ if show_progress:
+ print(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
+ await _emit_download_progress(
+ progress_callback,
+ {
+ "url": url,
+ "downloaded": 0,
+ "total": total_size,
+ "percent": 0,
+ "speed": 0,
+ },
+ )
+ with open(path, "wb") as f:
+ while True:
+ chunk = await resp.content.read(8192)
+ if not chunk:
+ break
+ await f.write(chunk)
+ downloaded_size += len(chunk)
+ elapsed_time = (
+ time.time() - start_time if time.time() - start_time > 0 else 1
+ )
+ speed = downloaded_size / 1024 / elapsed_time # KB/s
+ percent = downloaded_size / total_size if total_size > 0 else 0
+ await _emit_download_progress(
+ progress_callback,
+ {
+ "url": url,
+ "downloaded": downloaded_size,
+ "total": total_size,
+ "percent": percent,
+ "speed": speed,
+ },
+ )
+ if show_progress:
+ print(
+ f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
+ end="",
+ )
+ await _emit_download_progress(
+ progress_callback,
+ {
+ "url": url,
+ "downloaded": downloaded_size,
+ "total": total_size,
+ "percent": 1,
+ "speed": 0,
+ },
+ )
if show_progress:
logger.info("下载完成")
@@ -298,20 +380,68 @@ async def get_public_ip_address() -> list[IPv4Address | IPv6Address]:
return list(found_ips.values())
+def _read_dashboard_dist_version(dist_dir: str | Path) -> str | None:
+ version_file = Path(dist_dir) / "assets" / "version"
+ if version_file.exists():
+ return version_file.read_text(encoding="utf-8").strip()
+ return None
+
+
+def get_bundled_dashboard_dist_path() -> Path:
+ return Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist"
+
+
+def _normalize_dashboard_version(version: str) -> str:
+ version = version.strip()
+ if version[:1].lower() == "v":
+ version = version[1:]
+ if not re.match(
+ r"^[0-9]+(?:\.[0-9]+)*"
+ r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
+ r"(?:\+.+)?$",
+ version,
+ ):
+ raise ValueError(f"invalid dashboard version: {version!r}")
+ return version
+
+
+def should_use_bundled_dashboard_dist(
+ user_dist: str | Path,
+ current_version: str,
+) -> bool:
+ user_version = _read_dashboard_dist_version(user_dist)
+ bundled_dist = get_bundled_dashboard_dist_path()
+ if user_version is None or not bundled_dist.exists():
+ return False
+ try:
+ return (
+ VersionComparator.compare_version(
+ _normalize_dashboard_version(current_version),
+ _normalize_dashboard_version(user_version),
+ )
+ > 0
+ )
+ except (TypeError, ValueError):
+ return False
+
+
async def get_dashboard_version():
# First check user data directory (manually updated / downloaded dashboard).
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
- if not await anyio.Path(dist_dir).exists():
- # Fall back to the dist bundled inside the installed wheel.
- _bundled = Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist"
- if _bundled.exists():
- dist_dir = str(_bundled)
- if await anyio.Path(dist_dir).exists():
- version_file = os.path.join(dist_dir, "assets", "version")
- if await anyio.Path(version_file).exists():
- async with await anyio.open_file(version_file, encoding="utf-8") as f:
- v = (await f.read()).strip()
- return v
+ if os.path.exists(dist_dir):
+ from astrbot.core.config.default import VERSION
+
+ if should_use_bundled_dashboard_dist(dist_dir, VERSION):
+ bundled_version = _read_dashboard_dist_version(
+ get_bundled_dashboard_dist_path(),
+ )
+ if bundled_version is not None:
+ return bundled_version
+ return _read_dashboard_dist_version(dist_dir)
+
+ bundled = get_bundled_dashboard_dist_path()
+ if bundled.exists():
+ return _read_dashboard_dist_version(bundled)
return None
@@ -321,6 +451,7 @@ async def download_dashboard(
latest: bool = True,
version: str | None = None,
proxy: str | None = None,
+ progress_callback=None,
) -> None:
"""下载管理面板文件"""
if path is None:
@@ -339,23 +470,26 @@ async def download_dashboard(
dashboard_release_url,
str(zip_path),
show_progress=True,
+ progress_callback=progress_callback,
)
except BaseException as _:
if latest:
# Resolve latest release tag from GitHub API to construct correct asset URL
ssl_context = ssl.create_default_context(cafile=certifi.where())
- async with aiohttp.ClientSession(
- connector=aiohttp.TCPConnector(ssl=ssl_context),
- trust_env=True,
- ) as session:
- async with session.get(
+ async with (
+ aiohttp.ClientSession(
+ connector=aiohttp.TCPConnector(ssl=ssl_context),
+ trust_env=True,
+ ) as session,
+ session.get(
"https://api.github.com/repos/AstrBotDevs/AstrBot/releases/latest",
timeout=30,
headers={"Accept": "application/vnd.github+json"},
- ) as api_resp:
- api_resp.raise_for_status()
- release_data = await api_resp.json()
- tag = release_data["tag_name"]
+ ) as api_resp,
+ ):
+ api_resp.raise_for_status()
+ release_data = await api_resp.json()
+ tag = release_data["tag_name"]
else:
tag = version
dashboard_release_url = f"https://github.com/AstrBotDevs/AstrBot/releases/download/{tag}/AstrBot-{tag}-dashboard.zip"
@@ -365,12 +499,18 @@ async def download_dashboard(
dashboard_release_url,
str(zip_path),
show_progress=True,
+ progress_callback=progress_callback,
)
else:
url = f"https://github.com/AstrBotDevs/astrbot-release-harbour/releases/download/release-{version}/dist.zip"
logger.info(f"Downloading AstrBot WebUI from {url}")
if proxy:
url = f"{proxy}/{url}"
- await download_file(url, str(zip_path), show_progress=True)
+ await download_file(
+ url,
+ str(zip_path),
+ show_progress=True,
+ progress_callback=progress_callback,
+ )
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_path)
diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py
index 36fa7b221..2577285a8 100644
--- a/astrbot/core/utils/media_utils.py
+++ b/astrbot/core/utils/media_utils.py
@@ -17,6 +17,7 @@ from PIL import Image as PILImage
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
IMAGE_COMPRESS_DEFAULT_MAX_SIZE = 1280
IMAGE_COMPRESS_DEFAULT_QUALITY = 95
@@ -70,80 +71,12 @@ async def get_media_duration(file_path: str) -> int | None:
async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) -> str:
- """使用ffmpeg将音频转换为opus格式
-
- Args:
- audio_path: 原始音频文件路径
- output_path: 输出文件路径,如果为None则自动生成
-
- Returns:
- 转换后的opus文件路径
-
- Raises:
- Exception: 转换失败时抛出异常
-
- """
- # 如果已经是opus格式,直接返回
- if audio_path.lower().endswith(".opus"):
- return audio_path
-
- # 生成输出文件路径
- if output_path is None:
- temp_dir = get_astrbot_temp_path()
- await anyio.Path(temp_dir).mkdir(parents=True, exist_ok=True)
- output_path = os.path.join(temp_dir, f"media_audio_{uuid.uuid4().hex}.opus")
-
- try:
- # 使用ffmpeg转换为opus格式
- # -y: 覆盖输出文件
- # -i: 输入文件
- # -acodec libopus: 使用opus编码器
- # -ac 1: 单声道
- # -ar 16000: 采样率16kHz
- process = await asyncio.create_subprocess_exec(
- "ffmpeg",
- "-y",
- "-i",
- audio_path,
- "-acodec",
- "libopus",
- "-ac",
- "1",
- "-ar",
- "16000",
- output_path,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
-
- _stdout, stderr = await process.communicate()
-
- if process.returncode != 0:
- # 清理可能已生成但无效的临时文件
- if output_path and await anyio.Path(output_path).exists():
- try:
- await anyio.Path(output_path).unlink()
- logger.debug(
- f"[Media Utils] 已清理失败的opus输出文件: {output_path}",
- )
- except OSError as e:
- logger.warning(f"[Media Utils] 清理失败的opus输出文件时出错: {e}")
-
- error_msg = stderr.decode() if stderr else "未知错误"
- logger.error(f"[Media Utils] ffmpeg转换音频失败: {error_msg}")
- raise Exception(f"ffmpeg conversion failed: {error_msg}")
-
- logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}")
- return output_path
-
- except FileNotFoundError:
- logger.error(
- "[Media Utils] ffmpeg未安装或不在PATH中,无法转换音频格式。请安装ffmpeg: https://ffmpeg.org/",
- )
- raise Exception("ffmpeg not found")
- except Exception as e:
- logger.error(f"[Media Utils] 转换音频格式时出错: {e}")
- raise
+ """将音频转换为opus格式。"""
+ return await convert_audio_format(
+ audio_path=audio_path,
+ output_format="opus",
+ output_path=output_path,
+ )
async def convert_video_format(
@@ -252,7 +185,24 @@ async def convert_audio_format(
args = ["ffmpeg", "-y", "-i", audio_path]
if output_format == "amr":
- args.extend(["-ac", "1", "-ar", "8000", "-ab", "12.2k"])
+ args.extend(
+ [
+ "-ac",
+ "1",
+ "-ar",
+ "8000",
+ "-ab",
+ "12.2k",
+ "-af",
+ (
+ "highpass=f=310:poles=2,"
+ "lowpass=f=3720:poles=2,"
+ "equalizer=f=3150:width_type=h:width=1000:g=7.5,"
+ "loudnorm=I=-18.5:TP=-1.5:LRA=6,"
+ "aresample=8000"
+ ),
+ ],
+ )
elif output_format == "ogg" or output_format == "opus":
args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"])
args.append(output_path)
@@ -304,9 +254,17 @@ async def ensure_wav(audio_path: str, output_path: str | None = None) -> str:
if not audio_path:
return audio_path
- if _get_audio_magic_type(audio_path) == "wav":
+ audio_type = _get_audio_magic_type(audio_path)
+ if audio_type == "wav":
return audio_path
+ if audio_type == "silk":
+ if output_path is None:
+ temp_dir = get_astrbot_temp_path()
+ os.makedirs(temp_dir, exist_ok=True)
+ output_path = os.path.join(temp_dir, f"media_audio_{uuid.uuid4().hex}.wav")
+ return await tencent_silk_to_wav(audio_path, output_path)
+
return await convert_audio_to_wav(audio_path, output_path)
@@ -345,7 +303,11 @@ def _get_audio_magic_type(audio_path: str) -> str:
if header[:4] == b"ftyp" and b"mp4" in header[:8]:
return "mp4"
- if header[:8] == b"#!SILK_V3":
+ if header.startswith(b"#!SILK_V3"):
+ return "silk"
+
+ # Tencent SILK: leading \x02 byte before #!SILK_V3
+ if header.startswith(b"\x02#!SILK_V3"):
return "silk"
return ""
@@ -463,7 +425,7 @@ async def compress_image(
if not local_path.exists():
return url_or_path
if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
- local_path
+ local_path,
):
return url_or_path
with local_path.open("rb") as f:
diff --git a/astrbot/core/utils/metrics.py b/astrbot/core/utils/metrics.py
index 6831eaca9..213a31852 100644
--- a/astrbot/core/utils/metrics.py
+++ b/astrbot/core/utils/metrics.py
@@ -81,7 +81,7 @@ class Metric:
(key, Metric._format_group_value(value))
for key, value in kwargs.items()
if key not in Metric._counter_fields
- )
+ ),
)
@staticmethod
diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py
index 4f4421b4c..8f53a2ab3 100644
--- a/astrbot/core/utils/network_utils.py
+++ b/astrbot/core/utils/network_utils.py
@@ -117,11 +117,14 @@ def create_proxy_client(
Returns:
An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided.
+
"""
resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify
if proxy:
logger.info(f"[{provider_label}] 使用代理: {proxy}")
return httpx_module.AsyncClient(
- proxy=proxy, verify=resolved_verify, headers=headers
+ proxy=proxy,
+ verify=resolved_verify,
+ headers=headers,
)
return httpx_module.AsyncClient(verify=resolved_verify, headers=headers)
diff --git a/astrbot/core/utils/t2i/template_manager.py b/astrbot/core/utils/t2i/template_manager.py
index 72eb2216e..15dfe3040 100644
--- a/astrbot/core/utils/t2i/template_manager.py
+++ b/astrbot/core/utils/t2i/template_manager.py
@@ -1,11 +1,53 @@
# astrbot/core/utils/t2i/template_manager.py
+import logging
import os
+import re
import shutil
from typing import ClassVar
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path
+logger = logging.getLogger("astrbot")
+
+_ALLOWED_VARS = frozenset({"text", "version", "shiki_runtime"})
+
+_SSTI_BLACKLIST: list[tuple[str, re.Pattern]] = [
+ (
+ "dunder_chain",
+ re.compile(
+ r"__\s*(class|globals|init|mro|base|bases|subclasses|reduce|getitem|builtins|import|self|func|code|reduce_ex)__",
+ ),
+ ),
+ (
+ "dangerous_builtins",
+ re.compile(
+ r"\b(import\s+(?!url)|os\.\w+|subprocess\.|\.popen\(|eval\(|exec\()",
+ ),
+ ),
+ ("flask_context", re.compile(r"\{\{.*?\b(config|request|session|g)\b.*?\}\}")),
+]
+
+_VAR_RE = re.compile(r"\{\{\s*(\w+)\s*(\|[^}]*)?\}\}")
+
+
+def validate_template_content(content: str, *, strict: bool = False) -> None:
+ for label, pattern in _SSTI_BLACKLIST:
+ if pattern.search(content):
+ logger.warning(f"SSTI validation blocked template: matched rule [{label}]")
+ raise ValueError(f"Template contains forbidden pattern ({label}).")
+ if strict:
+ for m in _VAR_RE.finditer(content):
+ var = m.group(1)
+ if var not in _ALLOWED_VARS:
+ logger.warning(
+ f"SSTI validation blocked template: unauthorized variable '{var}'",
+ )
+ raise ValueError(
+ f"Unauthorized Jinja2 variable '{var}'; "
+ f"allowed: {', '.join(sorted(_ALLOWED_VARS))}.",
+ )
+
class TemplateManager:
"""负责管理 t2i HTML 模板的 CRUD 和重置操作。
@@ -82,7 +124,8 @@ class TemplateManager:
raise FileNotFoundError("模板不存在。")
def create_template(self, name: str, content: str) -> None:
- """在用户目录中创建一个新的模板文件。"""
+ """在用户目录中创建一个新的模板文件。"""
+ validate_template_content(content, strict=True)
path = self._get_user_template_path(name)
if os.path.exists(path):
raise FileExistsError("同名模板已存在。")
@@ -94,6 +137,7 @@ class TemplateManager:
如果更新的是一个内置模板,此操作实际上会在用户目录中创建一个修改后的副本,
从而实现对内置模板的“覆盖”。
"""
+ validate_template_content(content, strict=True)
path = self._get_user_template_path(name)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py
index 768fe2773..42c936ad0 100644
--- a/astrbot/core/utils/tencent_record_helper.py
+++ b/astrbot/core/utils/tencent_record_helper.py
@@ -5,7 +5,6 @@ import subprocess
import tempfile
import wave
from io import BytesIO
-from typing import TYPE_CHECKING
import anyio
import pysilk # requires silk-python (core dependency)
@@ -13,9 +12,6 @@ import pysilk # requires silk-python (core dependency)
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
-if TYPE_CHECKING:
- pass # pilk/p Moffmpeg are runtime optional deps
-
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
async with await anyio.open_file(silk_path, "rb") as f:
diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py
index 15096cc81..dc813542c 100644
--- a/astrbot/core/zip_updator.py
+++ b/astrbot/core/zip_updator.py
@@ -1,6 +1,8 @@
+import inspect
import os
import re
import shutil
+import time
import zipfile
from pathlib import Path
from typing import NoReturn
@@ -53,18 +55,64 @@ class RepoZipUpdator:
return body[:max_len] + "...[truncated]"
async def _download_file(
- self, url: str, path: str, timeout: float = 1800.0
+ self,
+ url: str,
+ path: str,
+ timeout: float = 1800.0,
+ progress_callback=None,
) -> None:
target_path = Path(path)
ensure_dir(target_path.parent)
+ async def _emit_progress(payload: dict) -> None:
+ if not progress_callback:
+ return
+ result = progress_callback(payload)
+ if inspect.isawaitable(result):
+ await result
+
try:
async with self._create_httpx_client(timeout=timeout) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
+ headers = getattr(response, "headers", {})
+ total_size = int(headers.get("content-length", 0))
+ downloaded_size = 0
+ start_time = time.time()
+ await _emit_progress(
+ {
+ "url": url,
+ "downloaded": 0,
+ "total": total_size,
+ "percent": 0,
+ "speed": 0,
+ },
+ )
with target_path.open("wb") as file:
async for chunk in response.aiter_bytes(8192):
file.write(chunk)
+ downloaded_size += len(chunk)
+ elapsed_time = max(time.time() - start_time, 1)
+ await _emit_progress(
+ {
+ "url": url,
+ "downloaded": downloaded_size,
+ "total": total_size,
+ "percent": downloaded_size / total_size
+ if total_size > 0
+ else 0,
+ "speed": downloaded_size / 1024 / elapsed_time,
+ },
+ )
+ await _emit_progress(
+ {
+ "url": url,
+ "downloaded": downloaded_size,
+ "total": total_size,
+ "percent": 1,
+ "speed": 0,
+ },
+ )
except Exception as e:
logger.error(f"下载文件失败: {url} -> {target_path}, 错误: {e}")
if self.rm_on_error and target_path.exists():
@@ -175,7 +223,10 @@ class RepoZipUpdator:
)
async def download_from_repo_url(
- self, target_path: str, repo_url: str, proxy=""
+ self,
+ target_path: str,
+ repo_url: str,
+ proxy="",
) -> None:
author, repo, branch = self.parse_github_url(repo_url)
@@ -248,7 +299,9 @@ class RepoZipUpdator:
root_candidates: list[str] = []
for raw_entry, normalized_entry, portable_entry in zip(
- entries, normalized_entries, portable_entries
+ entries,
+ normalized_entries,
+ portable_entries,
):
if normalized_entry == ".":
continue
@@ -314,7 +367,7 @@ class RepoZipUpdator:
os.remove(zip_path)
except Exception:
logger.warning(
- f"删除更新文件失败,可以手动删除 {zip_path} 和 {update_root_path}"
+ f"删除更新文件失败,可以手动删除 {zip_path} 和 {update_root_path}",
)
def format_name(self, name: str) -> str:
diff --git a/astrbot/dashboard/password_state.py b/astrbot/dashboard/password_state.py
new file mode 100644
index 000000000..8fa64a7aa
--- /dev/null
+++ b/astrbot/dashboard/password_state.py
@@ -0,0 +1,94 @@
+from astrbot.core.config.astrbot_config import AstrBotConfig
+from astrbot.core.db import BaseDatabase
+from astrbot.core.utils.auth_password import (
+ hash_dashboard_password,
+ hash_legacy_dashboard_password,
+ is_legacy_dashboard_password,
+)
+
+PASSWORD_STORAGE_UPGRADED_KEY = "password_storage_upgraded"
+PASSWORD_CHANGE_REQUIRED_KEY = "password_change_required"
+
+
+def _set_dashboard_flag(config: AstrBotConfig, key: str, value: bool) -> None:
+ if config["dashboard"].get(key) == bool(value):
+ return
+ config["dashboard"][key] = bool(value)
+ config.save_config()
+
+
+def _has_usable_pbkdf2_password(config: AstrBotConfig) -> bool:
+ password = config["dashboard"].get("pbkdf2_password", "")
+ if not isinstance(password, str) or not password.startswith("pbkdf2_sha256$"):
+ return False
+
+ parts = password.split("$")
+ if len(parts) != 4:
+ return False
+
+ _, iterations, salt, digest = parts
+ try:
+ int(iterations)
+ bytes.fromhex(salt)
+ bytes.fromhex(digest)
+ except ValueError:
+ return False
+ return True
+
+
+async def is_password_storage_upgraded(
+ db: BaseDatabase,
+ config: AstrBotConfig,
+) -> bool:
+ config_upgraded = _has_usable_pbkdf2_password(config)
+ if config["dashboard"].get(PASSWORD_STORAGE_UPGRADED_KEY) != config_upgraded:
+ _set_dashboard_flag(config, PASSWORD_STORAGE_UPGRADED_KEY, config_upgraded)
+ return config_upgraded
+
+
+async def set_password_storage_upgraded(
+ db: BaseDatabase,
+ config: AstrBotConfig,
+ upgraded: bool,
+) -> None:
+ _set_dashboard_flag(config, PASSWORD_STORAGE_UPGRADED_KEY, upgraded)
+
+
+async def is_password_change_required(
+ db: BaseDatabase,
+ config: AstrBotConfig,
+) -> bool:
+ stored = config["dashboard"].get(PASSWORD_CHANGE_REQUIRED_KEY, None)
+ if stored is not None:
+ return bool(stored)
+
+ required = bool(
+ getattr(config, "_generated_dashboard_password_change_required", False)
+ or getattr(config, "_dashboard_password_change_required_from_config", False),
+ )
+ if required:
+ _set_dashboard_flag(config, PASSWORD_CHANGE_REQUIRED_KEY, True)
+ return required
+
+
+async def set_password_change_required(
+ db: BaseDatabase,
+ config: AstrBotConfig,
+ required: bool,
+) -> None:
+ _set_dashboard_flag(config, PASSWORD_CHANGE_REQUIRED_KEY, required)
+
+
+def get_dashboard_password_hash(config: AstrBotConfig, *, upgraded: bool) -> str:
+ if upgraded and _has_usable_pbkdf2_password(config):
+ return config["dashboard"].get("pbkdf2_password", "")
+
+ legacy_password = config["dashboard"].get("password", "")
+ if upgraded and not is_legacy_dashboard_password(legacy_password):
+ return ""
+ return legacy_password
+
+
+def set_dashboard_password_hashes(config: AstrBotConfig, raw_password: str) -> None:
+ config["dashboard"]["pbkdf2_password"] = hash_dashboard_password(raw_password)
+ config["dashboard"]["password"] = hash_legacy_dashboard_password(raw_password)
diff --git a/astrbot/dashboard/plugin_page_auth.py b/astrbot/dashboard/plugin_page_auth.py
index f2571b3ee..fadfbd50f 100644
--- a/astrbot/dashboard/plugin_page_auth.py
+++ b/astrbot/dashboard/plugin_page_auth.py
@@ -11,7 +11,7 @@ class PluginPageAuth:
@staticmethod
def is_protected_path(path: str) -> bool:
return path.startswith(PLUGIN_PAGE_CONTENT_PREFIX) or path.startswith(
- PLUGIN_PAGE_BRIDGE_PATH
+ PLUGIN_PAGE_BRIDGE_PATH,
)
@staticmethod
diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py
index e5d5283e1..7450deff2 100644
--- a/astrbot/dashboard/routes/auth.py
+++ b/astrbot/dashboard/routes/auth.py
@@ -1,36 +1,67 @@
import asyncio
import datetime
+import os
import secrets
import jwt
-from quart import current_app, jsonify, make_response, request
+from quart import current_app, g, jsonify, make_response, request
from astrbot import logger
from astrbot.core import DEMO_MODE
from astrbot.core.utils.auth_password import (
get_dashboard_login_challenge,
- hash_dashboard_password,
is_default_dashboard_password,
is_legacy_dashboard_password,
validate_dashboard_password,
verify_dashboard_login_proof,
verify_dashboard_password,
)
+from astrbot.dashboard.password_state import (
+ get_dashboard_password_hash,
+ is_password_change_required,
+ is_password_storage_upgraded,
+ set_dashboard_password_hashes,
+ set_password_change_required,
+ set_password_storage_upgraded,
+)
from .route import Response, Route, RouteContext
DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt"
DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
+SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"
+SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"
+LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"}
+DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE = (
+ "Login failed. If this is your first time using AstrBot, the old default "
+ "astrbot password has been replaced by a random strong password printed in "
+ "the startup logs. Check the initial password in the logs and try again. "
+ "Learn more: https://docs.astrbot.app/en/faq.html\n\n"
+ "登录失败。如果您是初次使用,旧版默认 astrbot 密码已改为启动日志中输出的"
+ "随机强密码。请使用日志中提供的的初始密码来登录。了解更多:"
+ "https://docs.astrbot.app/faq.html"
+)
+LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE = (
+ "Incorrect username or password. If you cannot log in after upgrading "
+ "AstrBot even though the password is correct, see "
+ "https://docs.astrbot.app/en/faq.html\n\n"
+ "用户名或密码错误。如果你在升级 AstrBot 后遇到了密码正确但无法登录的情况,"
+ "请参考 https://docs.astrbot.app/faq.html"
+)
class AuthRoute(Route):
- def __init__(self, context: RouteContext) -> None:
+ def __init__(self, context: RouteContext, db) -> None:
super().__init__(context)
+ self.db = db
self._login_challenges: dict[str, dict[str, object]] = {}
self.routes = {
"/auth/login/challenge": ("POST", self.login_challenge),
"/auth/login": ("POST", self.login),
"/auth/logout": ("POST", self.logout),
+ "/auth/setup-status": ("GET", self.setup_status),
+ "/auth/setup": ("POST", self.setup),
+ "/auth/setup-authenticated": ("POST", self.setup_authenticated),
"/auth/account/edit": ("POST", self.edit_account),
}
self.register_routes()
@@ -69,9 +100,84 @@ class AuthRoute(Route):
.__dict__
)
+ async def setup_status(self):
+ return (
+ Response()
+ .ok(
+ {
+ "setup_required": await self._is_setup_required(),
+ "skip_default_password_auth": self._can_skip_default_password_auth(),
+ "password_upgrade_required": not await is_password_storage_upgraded(
+ self.db,
+ self.config,
+ ),
+ },
+ )
+ .__dict__
+ )
+
+ async def setup(self):
+ if not self._can_skip_default_password_auth():
+ return Response().error("Setup without password is not enabled").__dict__
+ if not await self._is_setup_required():
+ return Response().error("Setup is not required").__dict__
+
+ return await self._complete_setup()
+
+ async def setup_authenticated(self):
+ if not await self._is_setup_required():
+ return Response().error("Setup is not required").__dict__
+ if not isinstance(getattr(g, "username", None), str):
+ return Response().error("未授权").__dict__
+
+ return await self._complete_setup()
+
+ async def _complete_setup(self):
+ post_data = await request.json
+ if not isinstance(post_data, dict):
+ return Response().error("Invalid request payload").__dict__
+
+ new_username = post_data.get("username")
+ new_password = post_data.get("password")
+ confirm_password = post_data.get("confirm_password")
+ if not isinstance(new_username, str) or len(new_username.strip()) < 3:
+ return Response().error("用户名长度至少3位").__dict__
+ if not isinstance(new_password, str):
+ return Response().error("新密码无效").__dict__
+ if not isinstance(confirm_password, str) or confirm_password != new_password:
+ return Response().error("两次输入的新密码不一致").__dict__
+
+ try:
+ validate_dashboard_password(new_password)
+ except ValueError as e:
+ return Response().error(str(e)).__dict__
+
+ username = new_username.strip()
+ self.config["dashboard"]["username"] = username
+ set_dashboard_password_hashes(self.config, new_password)
+ await set_password_storage_upgraded(self.db, self.config, True)
+ await set_password_change_required(self.db, self.config, False)
+ self.config.save_config()
+
+ token = self.generate_jwt(username)
+ payload = Response().ok(
+ {
+ "token": token,
+ "username": username,
+ "change_pwd_hint": False,
+ "legacy_pwd_hint": False,
+ "password_upgrade_required": False,
+ },
+ "Setup completed successfully",
+ )
+ response = await make_response(jsonify(payload.__dict__))
+ self._set_dashboard_jwt_cookie(response, token)
+ return response
+
async def login(self):
- stored_username = self.config["dashboard"]["username"]
- stored_password = self.config["dashboard"]["password"]
+ username = self.config["dashboard"]["username"]
+ storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
+ password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded)
post_data = await request.json
req_username = (
@@ -86,101 +192,75 @@ class AuthRoute(Route):
req_password_proof = (
post_data.get("password_proof") if isinstance(post_data, dict) else None
)
- if not isinstance(req_username, str) or not isinstance(req_password, str):
+ if not isinstance(req_username, str):
+ return Response().error("Invalid request payload").__dict__
+ has_password = isinstance(req_password, str)
+ has_challenge = isinstance(req_challenge_id, str) and isinstance(
+ req_password_proof,
+ str,
+ )
+ if not has_password and not has_challenge:
return Response().error("Invalid request payload").__dict__
- # First login: if password is not configured (empty/default), trust the user's input
- if is_default_dashboard_password(stored_password) and not DEMO_MODE:
- # First-time setup: save user's input as admin credentials
- new_password_hash = hash_dashboard_password(req_password)
- self.config["dashboard"]["username"] = req_username
- self.config["dashboard"]["password"] = new_password_hash
- self.config.save_config()
- logger.warning(
- f"Dashboard admin configured via first login: username={req_username}",
- )
- return (
- Response()
- .ok(
- {
- "token": self.generate_jwt(req_username),
- "username": req_username,
- "change_pwd_hint": False,
- "legacy_pwd_hint": False,
- },
- )
- .__dict__
- )
-
login_verified = False
- if isinstance(req_password, str):
- login_verified = (
- req_username == stored_username
- and verify_dashboard_password(
- stored_password,
- req_password,
- )
+ if has_password:
+ login_verified = req_username == username and verify_dashboard_password(
+ password,
+ req_password,
)
- elif isinstance(req_challenge_id, str) and isinstance(req_password_proof, str):
+ if not login_verified and has_challenge:
challenge_nonce = self._consume_login_challenge(req_challenge_id)
login_verified = (
- req_username == stored_username
+ req_username == username
and isinstance(challenge_nonce, str)
and verify_dashboard_login_proof(
- stored_password,
+ password,
challenge_nonce,
req_password_proof,
)
)
- else:
- return Response().error("Invalid request payload").__dict__
if login_verified:
change_pwd_hint = False
- legacy_pwd_hint = is_legacy_dashboard_password(stored_password)
- if is_default_dashboard_password(stored_password) and not DEMO_MODE:
+ legacy_pwd_hint = is_legacy_dashboard_password(password)
+ password_change_required = await is_password_change_required(
+ self.db,
+ self.config,
+ )
+ if (
+ storage_upgraded
+ and username == "astrbot"
+ and is_default_dashboard_password(password)
+ and not DEMO_MODE
+ ):
change_pwd_hint = True
- logger.warning(
- "The dashboard is using the default password, please change it immediately to ensure security.",
- )
legacy_pwd_hint = True
-
- token = self.generate_jwt(stored_username)
+ logger.warning("为了保证安全,请尽快修改默认密码。")
+ if password_change_required and not DEMO_MODE:
+ change_pwd_hint = True
+ token = self.generate_jwt(username)
payload = Response().ok(
{
"token": token,
- "username": stored_username,
+ "username": username,
"change_pwd_hint": change_pwd_hint,
"legacy_pwd_hint": legacy_pwd_hint,
+ "password_upgrade_required": not storage_upgraded,
},
)
response = await make_response(jsonify(payload.__dict__))
self._set_dashboard_jwt_cookie(response, token)
return response
await asyncio.sleep(3)
- return Response().error("User not found or incorrect password").__dict__
-
- def _prune_login_challenges(self) -> None:
- now = datetime.datetime.now(datetime.timezone.utc)
- expired_ids = [
- challenge_id
- for challenge_id, challenge in self._login_challenges.items()
- if challenge.get("expires_at") <= now
- ]
- for challenge_id in expired_ids:
- self._login_challenges.pop(challenge_id, None)
-
- def _consume_login_challenge(self, challenge_id: str) -> str | None:
- self._prune_login_challenges()
- challenge = self._login_challenges.pop(challenge_id, None)
- if not isinstance(challenge, dict):
- return None
- nonce = challenge.get("nonce")
- return nonce if isinstance(nonce, str) else None
+ if req_password == "astrbot":
+ return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
+ if is_legacy_dashboard_password(password):
+ return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
+ return Response().error("用户名或密码错误").__dict__
async def logout(self):
response = await make_response(
- jsonify(Response().ok(None, "已退出登录").__dict__)
+ jsonify(Response().ok(None, "已退出登录").__dict__),
)
self._clear_dashboard_jwt_cookie(response)
return response
@@ -193,7 +273,8 @@ class AuthRoute(Route):
.__dict__
)
- password = self.config["dashboard"]["password"]
+ storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
+ password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded)
post_data = await request.json
if not isinstance(post_data, dict):
return Response().error("Invalid request payload").__dict__
@@ -207,6 +288,12 @@ class AuthRoute(Route):
new_pwd = post_data.get("new_password", None)
new_username = post_data.get("new_username", None)
+ password_change_required = await is_password_change_required(
+ self.db,
+ self.config,
+ )
+ if (not storage_upgraded or password_change_required) and not new_pwd:
+ return Response().error("请设置新密码以完成安全升级").__dict__
if not new_pwd and not new_username:
return Response().error("新用户名和新密码不能同时为空").__dict__
@@ -221,7 +308,9 @@ class AuthRoute(Route):
validate_dashboard_password(new_pwd)
except ValueError as e:
return Response().error(str(e)).__dict__
- self.config["dashboard"]["password"] = hash_dashboard_password(new_pwd)
+ set_dashboard_password_hashes(self.config, new_pwd)
+ await set_password_storage_upgraded(self.db, self.config, True)
+ await set_password_change_required(self.db, self.config, False)
if new_username:
self.config["dashboard"]["username"] = new_username
@@ -241,13 +330,52 @@ class AuthRoute(Route):
token = jwt.encode(payload, jwt_token, algorithm="HS256")
return token
+ async def _is_setup_required(self) -> bool:
+ if DEMO_MODE:
+ return False
+
+ dashboard_config = self.config["dashboard"]
+ password_change_required = await is_password_change_required(
+ self.db,
+ self.config,
+ )
+ if password_change_required:
+ return True
+
+ storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
+ if not storage_upgraded:
+ return False
+
+ return dashboard_config.get(
+ "username",
+ ) == "astrbot" and is_default_dashboard_password(
+ dashboard_config.get("pbkdf2_password", ""),
+ )
+
+ def _can_skip_default_password_auth(self) -> bool:
+ if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV):
+ return False
+ host = (
+ os.environ.get("DASHBOARD_HOST")
+ or os.environ.get("ASTRBOT_DASHBOARD_HOST")
+ or self.config["dashboard"].get("host", "")
+ )
+ return str(host).strip().lower() in LOCAL_DASHBOARD_HOSTS
+
+ @staticmethod
+ def _env_flag_enabled(name: str) -> bool:
+ value = os.environ.get(name)
+ if value is None and name == SKIP_DEFAULT_PASSWORD_AUTH_ENV:
+ value = os.environ.get(SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY)
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
+
@staticmethod
def _use_secure_dashboard_jwt_cookie() -> bool:
return bool(
current_app.config.get(
"DASHBOARD_JWT_COOKIE_SECURE",
not current_app.debug and not current_app.testing,
- )
+ ),
)
@staticmethod
@@ -271,3 +399,21 @@ class AuthRoute(Route):
secure=AuthRoute._use_secure_dashboard_jwt_cookie(),
path="/",
)
+
+ def _prune_login_challenges(self) -> None:
+ now = datetime.datetime.now(datetime.timezone.utc)
+ expired_ids = [
+ challenge_id
+ for challenge_id, challenge in self._login_challenges.items()
+ if challenge.get("expires_at") <= now
+ ]
+ for challenge_id in expired_ids:
+ self._login_challenges.pop(challenge_id, None)
+
+ def _consume_login_challenge(self, challenge_id: str) -> str | None:
+ self._prune_login_challenges()
+ challenge = self._login_challenges.pop(challenge_id, None)
+ if not isinstance(challenge, dict):
+ return None
+ nonce = challenge.get("nonce")
+ return nonce if isinstance(nonce, str) else None
diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py
index 8b5a65e5e..c48280916 100644
--- a/astrbot/dashboard/routes/chat.py
+++ b/astrbot/dashboard/routes/chat.py
@@ -141,7 +141,9 @@ class BotMessageAccumulator:
self.parts.append(part)
def build_message_parts(
- self, *, include_pending_tool_calls: bool = False
+ self,
+ *,
+ include_pending_tool_calls: bool = False,
) -> list[dict]:
self._flush_pending_text()
if include_pending_tool_calls and self.pending_tool_calls:
@@ -261,20 +263,24 @@ class ChatRoute(Route):
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
real_file_path = await asyncio.to_thread(os.path.realpath, file_path)
real_imgs_dir = await asyncio.to_thread(
- os.path.realpath, self.attachments_dir
+ os.path.realpath,
+ self.attachments_dir,
)
if not await asyncio.to_thread(os.path.exists, real_file_path):
# try legacy
file_path = os.path.join(
- self.legacy_img_dir, os.path.basename(filename)
+ self.legacy_img_dir,
+ os.path.basename(filename),
)
if await asyncio.to_thread(os.path.exists, file_path):
real_file_path = await asyncio.to_thread(
- os.path.realpath, file_path
+ os.path.realpath,
+ file_path,
)
real_imgs_dir = await asyncio.to_thread(
- os.path.realpath, self.legacy_img_dir
+ os.path.realpath,
+ self.legacy_img_dir,
)
if not real_file_path.startswith(real_imgs_dir):
@@ -351,7 +357,7 @@ class ChatRoute(Route):
"attachment_id": attachment.attachment_id,
"filename": filename,
"type": attach_type,
- }
+ },
)
.__dict__
)
@@ -365,7 +371,9 @@ class ChatRoute(Route):
)
async def _create_attachment_from_file(
- self, filename: str, attach_type: str
+ self,
+ filename: str,
+ attach_type: str,
) -> dict | None:
"""从本地文件创建 attachment 并返回消息部分。"""
return await create_attachment_part_from_existing_file(
@@ -377,7 +385,9 @@ class ChatRoute(Route):
)
def _extract_web_search_refs(
- self, accumulated_text: str, accumulated_parts: list
+ self,
+ accumulated_text: str,
+ accumulated_parts: list,
) -> dict:
"""从消息中提取 web_search_tavily 的引用
@@ -387,6 +397,7 @@ class ChatRoute(Route):
Returns:
包含 used 列表的字典,记录被引用的搜索结果
+
"""
supported = [
"web_search_baidu",
@@ -405,7 +416,7 @@ class ChatRoute(Route):
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") not in supported or not tool_call.get(
- "result"
+ "result",
):
continue
try:
@@ -500,7 +511,8 @@ class ChatRoute(Route):
async def _delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None:
for thread_id in thread_ids:
unified_msg_origin = self._build_thread_unified_msg_origin(
- creator, thread_id
+ creator,
+ thread_id,
)
active_event_registry.request_agent_stop_all(unified_msg_origin)
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
@@ -515,7 +527,7 @@ class ChatRoute(Route):
async def _load_current_conversation_history(self, session) -> tuple[str, list]:
unified_msg_origin = self._build_webchat_unified_msg_origin(session)
conversation_id = await self.conv_mgr.get_curr_conversation_id(
- unified_msg_origin
+ unified_msg_origin,
)
if not conversation_id:
return "", []
@@ -534,7 +546,9 @@ class ChatRoute(Route):
return conversation_id, history if isinstance(history, list) else []
def _find_checkpoint_index(
- self, history: list[dict], checkpoint_id: str
+ self,
+ history: list[dict],
+ checkpoint_id: str,
) -> int | None:
for index, message in enumerate(history):
if get_checkpoint_id(message) == checkpoint_id:
@@ -542,7 +556,9 @@ class ChatRoute(Route):
return None
def _find_turn_range(
- self, history: list[dict], checkpoint_id: str
+ self,
+ history: list[dict],
+ checkpoint_id: str,
) -> tuple[int, int] | None:
checkpoint_index = self._find_checkpoint_index(history, checkpoint_id)
if checkpoint_index is None:
@@ -626,7 +642,10 @@ class ChatRoute(Route):
return result
def _find_turn_user_index(
- self, history: list[dict], start: int, end: int
+ self,
+ history: list[dict],
+ start: int,
+ end: int,
) -> int | None:
for index in range(start, end):
message = history[index]
@@ -635,7 +654,10 @@ class ChatRoute(Route):
return None
def _find_turn_final_assistant_index(
- self, history: list[dict], start: int, end: int
+ self,
+ history: list[dict],
+ start: int,
+ end: int,
) -> int | None:
for index in range(end - 1, start - 1, -1):
message = history[index]
@@ -657,7 +679,9 @@ class ChatRoute(Route):
return history_list
async def _delete_platform_history_after(
- self, session, message_id: int
+ self,
+ session,
+ message_id: int,
) -> list[int]:
history_list = await self._get_sorted_platform_history(session)
should_delete = False
@@ -778,7 +802,7 @@ class ChatRoute(Route):
"data": {
"id": saved_user_record.id,
"created_at": to_utc_isoformat(
- saved_user_record.created_at
+ saved_user_record.created_at,
),
"llm_checkpoint_id": llm_checkpoint_id,
},
@@ -788,7 +812,8 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
result, should_break = await _poll_webchat_stream_result(
- back_queue, username
+ back_queue,
+ username,
)
if should_break:
client_disconnected = True
@@ -829,7 +854,7 @@ class ChatRoute(Route):
except Exception as e:
if not client_disconnected:
logger.debug(
- f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
+ f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
)
client_disconnected = True
@@ -849,7 +874,7 @@ class ChatRoute(Route):
if accumulated_text:
# 如果累积了文本,则先保存文本
accumulated_parts.append(
- {"type": "plain", "text": accumulated_text}
+ {"type": "plain", "text": accumulated_text},
)
accumulated_text = ""
elif chain_type == "tool_call_result":
@@ -862,7 +887,7 @@ class ChatRoute(Route):
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
- }
+ },
)
tool_calls.pop(tc_id, None)
elif chain_type == "reasoning":
@@ -874,14 +899,16 @@ class ChatRoute(Route):
elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file(
- filename, "image"
+ filename,
+ "image",
)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = result_text.replace("[RECORD]", "")
part = await self._create_attachment_from_file(
- filename, "record"
+ filename,
+ "record",
)
if part:
accumulated_parts.append(part)
@@ -889,7 +916,8 @@ class ChatRoute(Route):
# 格式: [FILE]filename
filename = result_text.replace("[FILE]", "")
part = await self._create_attachment_from_file(
- filename, "file"
+ filename,
+ "file",
)
if part:
accumulated_parts.append(part)
@@ -936,7 +964,7 @@ class ChatRoute(Route):
"data": {
"id": saved_record.id,
"created_at": to_utc_isoformat(
- saved_record.created_at
+ saved_record.created_at,
),
"llm_checkpoint_id": llm_checkpoint_id,
},
@@ -987,7 +1015,7 @@ class ChatRoute(Route):
)
response = cast(
- QuartResponse,
+ "QuartResponse",
await make_response(
stream(),
{
@@ -1136,7 +1164,7 @@ class ChatRoute(Route):
"deleted_count": deleted_count,
"failed_count": len(failed_items),
"failed_items": failed_items,
- }
+ },
)
.__dict__
)
@@ -1165,7 +1193,7 @@ class ChatRoute(Route):
await asyncio.to_thread(os.remove, attachment.path)
except OSError as e:
logger.warning(
- f"Failed to delete attachment file {attachment.path}: {e}"
+ f"Failed to delete attachment file {attachment.path}: {e}",
)
except Exception as e:
logger.warning(f"Failed to get attachments: {e}")
@@ -1196,7 +1224,7 @@ class ChatRoute(Route):
data={
"session_id": session.session_id,
"platform_id": session.platform_id,
- }
+ },
)
.__dict__
)
@@ -1230,7 +1258,7 @@ class ChatRoute(Route):
"is_group": session.is_group,
"created_at": to_utc_isoformat(session.created_at),
"updated_at": to_utc_isoformat(session.updated_at),
- }
+ },
)
return Response().ok(data=sessions_data).__dict__
@@ -1248,7 +1276,8 @@ class ChatRoute(Route):
# 获取项目信息(如果会话属于某个项目)
username = g.get("username", "guest")
project_info = await self.db.get_project_by_session(
- session_id=session_id, creator=username
+ session_id=session_id,
+ creator=username,
)
# Get platform message history using session_id
@@ -1310,7 +1339,7 @@ class ChatRoute(Route):
return Response().error("Permission denied").__dict__
parent_record = await self.db.get_platform_message_history_by_id(
- parent_message_id
+ parent_message_id,
)
if (
not parent_record
@@ -1339,7 +1368,7 @@ class ChatRoute(Route):
return Response().ok(data=self._serialize_thread(existing)).__dict__
conversation_id, history = await self._load_current_conversation_history(
- session
+ session,
)
turn_range = self._find_turn_range(history, checkpoint_id)
if not conversation_id or not turn_range:
@@ -1390,7 +1419,7 @@ class ChatRoute(Route):
"thread": self._serialize_thread(thread),
"history": [history.model_dump() for history in history_ls],
"is_running": self.running_convs.get(thread_id, False),
- }
+ },
)
.__dict__
)
@@ -1421,7 +1450,7 @@ class ChatRoute(Route):
"selected_model": post_data.get("selected_model"),
"_platform_history_id": "webchat_thread",
"_thread_selected_text": thread.selected_text,
- }
+ },
)
async def delete_thread(self):
@@ -1507,7 +1536,7 @@ class ChatRoute(Route):
)
conversation_id, history = await self._load_current_conversation_history(
- session
+ session,
)
turn_range = self._find_turn_range(history, checkpoint_id)
if not conversation_id or not turn_range:
@@ -1529,7 +1558,8 @@ class ChatRoute(Route):
llm_checkpoint_id=new_checkpoint_id,
)
deleted_message_ids = await self._delete_platform_history_after(
- session, message_id
+ session,
+ message_id,
)
thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
session_id,
@@ -1550,7 +1580,7 @@ class ChatRoute(Route):
"message": updated.model_dump() if updated else None,
"needs_regenerate": True,
"truncated_after_message": True,
- }
+ },
)
.__dict__
)
@@ -1598,7 +1628,7 @@ class ChatRoute(Route):
return Response().error("Message is not linked to LLM history").__dict__
conversation_id, history = await self._load_current_conversation_history(
- session
+ session,
)
turn_range = self._find_turn_range(history, checkpoint_id)
if not conversation_id or not turn_range:
@@ -1668,7 +1698,7 @@ class ChatRoute(Route):
"selected_model": post_data.get("selected_model"),
"_skip_user_history": True,
"_llm_checkpoint_id": new_checkpoint_id,
- }
+ },
)
async def update_session_display_name(self):
diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py
index 16c605848..5c0ca5a87 100644
--- a/astrbot/dashboard/routes/live_chat.py
+++ b/astrbot/dashboard/routes/live_chat.py
@@ -60,7 +60,7 @@ class LiveChatSession:
start_time = time.time()
if not self.is_speaking or stamp != self.current_stamp:
logger.warning(
- f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}"
+ f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}",
)
return None, 0.0
@@ -86,7 +86,7 @@ class LiveChatSession:
self.temp_audio_path = audio_path
logger.info(
- f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes"
+ f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes",
)
return audio_path, time.time() - start_time
@@ -183,7 +183,9 @@ class LiveChatRoute(Route):
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
async def _create_attachment_from_file(
- self, filename: str, attach_type: str
+ self,
+ filename: str,
+ attach_type: str,
) -> dict | None:
"""从本地文件创建 attachment 并返回消息部分。"""
return await create_attachment_part_from_existing_file(
@@ -195,7 +197,9 @@ class LiveChatRoute(Route):
)
def _extract_web_search_refs(
- self, accumulated_text: str, accumulated_parts: list
+ self,
+ accumulated_text: str,
+ accumulated_parts: list,
) -> dict:
"""从消息中提取 web_search 引用。"""
supported = [
@@ -214,7 +218,7 @@ class LiveChatRoute(Route):
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") not in supported or not tool_call.get(
- "result"
+ "result",
):
continue
try:
@@ -291,7 +295,8 @@ class LiveChatRoute(Route):
request_id: str,
) -> None:
back_queue = webchat_queue_mgr.get_or_create_back_queue(
- request_id, chat_session_id
+ request_id,
+ chat_session_id,
)
try:
while True:
@@ -344,7 +349,9 @@ class LiveChatRoute(Route):
session.chat_subscription_tasks.clear()
async def _handle_chat_message(
- self, session: LiveChatSession, message: dict
+ self,
+ session: LiveChatSession,
+ message: dict,
) -> None:
"""处理 Chat Mode 消息(ct=chat)"""
msg_type = message.get("t")
@@ -551,7 +558,7 @@ class LiveChatRoute(Route):
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
accumulated_parts.append(
- {"type": "plain", "text": accumulated_text}
+ {"type": "plain", "text": accumulated_text},
)
accumulated_text = ""
except Exception:
@@ -567,7 +574,7 @@ class LiveChatRoute(Route):
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
- }
+ },
)
tool_calls.pop(tc_id, None)
except Exception:
@@ -606,7 +613,7 @@ class LiveChatRoute(Route):
or accumulated_text
or accumulated_reasoning
or refs
- or agent_stats
+ or agent_stats,
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in (
@@ -646,7 +653,7 @@ class LiveChatRoute(Route):
"data": {
"id": saved_record.id,
"created_at": to_utc_isoformat(
- saved_record.created_at
+ saved_record.created_at,
),
"llm_checkpoint_id": llm_checkpoint_id,
},
@@ -669,7 +676,7 @@ class LiveChatRoute(Route):
{
"ct": "chat",
"t": "error",
- "data": f"处理失败: {str(e)}",
+ "data": f"处理失败: {e!s}",
"code": "PROCESSING_ERROR",
},
)
@@ -733,13 +740,16 @@ class LiveChatRoute(Route):
logger.info(f"[Live Chat] 用户打断: {session.username}")
async def _process_audio(
- self, session: LiveChatSession, audio_path: str, assemble_duration: float
+ self,
+ session: LiveChatSession,
+ audio_path: str,
+ assemble_duration: float,
) -> None:
"""处理音频:STT -> LLM -> 流式 TTS"""
try:
# 发送 WAV 组装耗时
await websocket.send_json(
- {"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
+ {"t": "metrics", "data": {"wav_assemble_time": assemble_duration}},
)
wav_assembly_finish_time = time.time()
@@ -756,7 +766,7 @@ class LiveChatRoute(Route):
return
await websocket.send_json(
- {"t": "metrics", "data": {"stt": stt_provider.meta().type}}
+ {"t": "metrics", "data": {"stt": stt_provider.meta().type}},
)
user_text = await stt_provider.get_text(audio_path)
@@ -770,7 +780,7 @@ class LiveChatRoute(Route):
{
"t": "user_msg",
"data": {"text": user_text, "ts": int(time.time() * 1000)},
- }
+ },
)
# 2. 构造消息事件并发送到 pipeline
@@ -802,7 +812,9 @@ class LiveChatRoute(Route):
await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message(
- session, user_text, bot_text
+ session,
+ user_text,
+ bot_text,
)
# 清空队列中未处理的消息
while not back_queue.empty():
@@ -823,7 +835,7 @@ class LiveChatRoute(Route):
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
- f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
+ f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}",
)
continue
@@ -842,7 +854,7 @@ class LiveChatRoute(Route):
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
- }
+ },
)
except Exception as e:
logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
@@ -855,7 +867,7 @@ class LiveChatRoute(Route):
{
"t": "metrics",
"data": stats,
- }
+ },
)
except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
@@ -879,9 +891,9 @@ class LiveChatRoute(Route):
{
"t": "metrics",
"data": {
- "speak_to_first_frame": speak_to_first_frame_latency
+ "speak_to_first_frame": speak_to_first_frame_latency,
},
- }
+ },
)
text = result.get("text")
@@ -890,7 +902,7 @@ class LiveChatRoute(Route):
{
"t": "bot_text_chunk",
"data": {"text": text},
- }
+ },
)
# 发送音频数据给前端
@@ -898,7 +910,7 @@ class LiveChatRoute(Route):
{
"t": "response",
"data": data, # base64 编码的音频数据
- }
+ },
)
elif result_type in ["complete", "end"]:
@@ -914,7 +926,7 @@ class LiveChatRoute(Route):
"text": bot_text,
"ts": int(time.time() * 1000),
},
- }
+ },
)
# 发送结束标记
@@ -926,7 +938,7 @@ class LiveChatRoute(Route):
{
"t": "metrics",
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
- }
+ },
)
break
finally:
@@ -934,14 +946,17 @@ class LiveChatRoute(Route):
except Exception as e:
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
- await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
+ await websocket.send_json({"t": "error", "data": f"处理失败: {e!s}"})
finally:
session.is_processing = False
session.should_interrupt = False
async def _save_interrupted_message(
- self, session: LiveChatSession, user_text: str, bot_text: str
+ self,
+ session: LiveChatSession,
+ user_text: str,
+ bot_text: str,
) -> None:
"""保存被打断的消息"""
interrupted_text = bot_text + " [用户打断]"
@@ -951,11 +966,11 @@ class LiveChatRoute(Route):
try:
timestamp = int(time.time() * 1000)
logger.info(
- f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})"
+ f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})",
)
if bot_text:
logger.info(
- f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})"
+ f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})",
)
except Exception as e:
logger.error(f"[Live Chat] 记录消息失败: {e}", exc_info=True)
diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py
index 58db0fa1b..d210702bf 100644
--- a/astrbot/dashboard/routes/open_api.py
+++ b/astrbot/dashboard/routes/open_api.py
@@ -461,7 +461,7 @@ class OpenApiRoute(Route):
should_save = False
if msg_type == "end":
should_save = bool(
- message_accumulator.has_content() or refs or agent_stats
+ message_accumulator.has_content() or refs or agent_stats,
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in ("tool_call", "tool_call_result"):
@@ -469,10 +469,10 @@ class OpenApiRoute(Route):
if should_save:
message_parts_to_save = message_accumulator.build_message_parts(
- include_pending_tool_calls=True
+ include_pending_tool_calls=True,
)
plain_text = collect_plain_text_from_message_parts(
- message_parts_to_save
+ message_parts_to_save,
)
try:
refs = self.chat_route._extract_web_search_refs(
diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py
index 50917d161..3fcb93eb0 100644
--- a/astrbot/dashboard/routes/platform.py
+++ b/astrbot/dashboard/routes/platform.py
@@ -3,15 +3,35 @@
提供统一的 webhook 回调入口,支持多个平台使用同一端口接收回调。
"""
+import secrets
+import string
+
from quart import request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform import Platform
+from astrbot.core.platform.sources.dingtalk.app_registration import (
+ poll_dingtalk_app_registration_once,
+ request_dingtalk_app_registration,
+)
+from astrbot.core.platform.sources.lark.app_registration import (
+ poll_app_registration_once,
+ request_app_registration,
+)
+from astrbot.core.platform.sources.lark.bot_info import request_lark_bot_info
+from astrbot.core.platform.sources.weixin_oc.login_registration import (
+ poll_weixin_oc_login_once,
+ request_weixin_oc_login_qr,
+)
from .route import Response, Route, RouteContext
+def _random_platform_id_suffix() -> str:
+ return "_" + "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+
+
class PlatformRoute(Route):
"""统一 Webhook 路由"""
@@ -42,6 +62,12 @@ class PlatformRoute(Route):
methods=["GET"],
)
+ self.app.add_url_rule(
+ "/api/platform/registration/",
+ view_func=self.handle_platform_registration,
+ methods=["POST"],
+ )
+
async def unified_webhook_callback(self, webhook_uuid: str):
"""统一 webhook 回调入口
@@ -105,4 +131,163 @@ class PlatformRoute(Route):
return Response().ok(stats).to_json()
except Exception as e:
logger.error(f"获取平台统计信息失败: {e}", exc_info=True)
- return Response().error(f"获取统计信息失败: {e}").to_json(), 500
+ return Response().error(f"获取统计信息失败: {e}").__dict__, 500
+
+ async def handle_platform_registration(self, platform_type: str):
+ """Handle dashboard one-click platform registration actions."""
+ try:
+ payload = await request.get_json(silent=True) or {}
+ action = str(payload.get("action", "")).strip().lower()
+ if not action:
+ return Response().error("Missing action").__dict__, 400
+
+ platform_config = payload.get("platform_config")
+ if not isinstance(platform_config, dict):
+ platform_config = {}
+
+ if platform_type == "lark":
+ return await self._handle_lark_registration(
+ action,
+ payload,
+ platform_config,
+ )
+ if platform_type == "weixin_oc":
+ return await self._handle_weixin_oc_registration(
+ action,
+ payload,
+ platform_config,
+ )
+ if platform_type == "dingtalk":
+ return await self._handle_dingtalk_registration(action, payload)
+
+ return Response().error(
+ f"Unsupported platform registration: {platform_type}",
+ ).__dict__, 404
+ except Exception as e:
+ logger.error(f"处理平台一键创建请求失败: {e}", exc_info=True)
+ return Response().error(str(e)).__dict__, 500
+
+ async def _handle_lark_registration(
+ self,
+ action: str,
+ payload: dict,
+ platform_config: dict,
+ ):
+ domain = str(platform_config.get("domain") or "").strip()
+
+ if action == "start":
+ registration = await request_app_registration(domain)
+ return (
+ Response()
+ .ok(
+ {
+ "status": "pending",
+ "device_code": registration.device_code,
+ "registration_code": registration.device_code,
+ "user_code": registration.user_code,
+ "verification_uri": registration.verification_uri,
+ "verification_uri_complete": registration.verification_uri_complete,
+ "expires_in": registration.expires_in,
+ "interval": registration.interval,
+ },
+ )
+ .__dict__
+ )
+
+ if action == "poll":
+ device_code = str(
+ payload.get("device_code") or payload.get("registration_code") or "",
+ ).strip()
+ if not device_code:
+ return Response().error("Missing device_code").__dict__, 400
+ result = await poll_app_registration_once(
+ domain=domain,
+ device_code=device_code,
+ )
+ if result.get("status") == "created":
+ try:
+ bot_info = await request_lark_bot_info(
+ domain=str(result.get("domain") or domain),
+ app_id=str(result.get("app_id") or ""),
+ app_secret=str(result.get("app_secret") or ""),
+ )
+ if bot_info.app_name:
+ result["bot_name"] = bot_info.app_name
+ if bot_info.open_id:
+ result["bot_open_id"] = bot_info.open_id
+ except Exception as e:
+ logger.error(f"获取飞书机器人信息失败: {e}", exc_info=True)
+ return Response().ok(result).__dict__
+
+ return Response().error(f"Unsupported action: {action}").__dict__, 400
+
+ async def _handle_dingtalk_registration(self, action: str, payload: dict):
+ if action == "start":
+ registration = await request_dingtalk_app_registration()
+ return (
+ Response()
+ .ok(
+ {
+ "status": "pending",
+ "device_code": registration.device_code,
+ "registration_code": registration.device_code,
+ "user_code": registration.user_code,
+ "verification_uri": registration.verification_uri,
+ "verification_uri_complete": registration.verification_uri_complete,
+ "expires_in": registration.expires_in,
+ "interval": registration.interval,
+ },
+ )
+ .__dict__
+ )
+
+ if action == "poll":
+ device_code = str(
+ payload.get("device_code") or payload.get("registration_code") or "",
+ ).strip()
+ if not device_code:
+ return Response().error("Missing device_code").__dict__, 400
+ result = await poll_dingtalk_app_registration_once(device_code)
+ if result.get("status") == "created":
+ result["platform_id_suffix"] = _random_platform_id_suffix()
+ return Response().ok(result).__dict__
+
+ return Response().error(f"Unsupported action: {action}").__dict__, 400
+
+ async def _handle_weixin_oc_registration(
+ self,
+ action: str,
+ payload: dict,
+ platform_config: dict,
+ ):
+ if action == "start":
+ registration = await request_weixin_oc_login_qr(platform_config)
+ return (
+ Response()
+ .ok(
+ {
+ "status": "pending",
+ "registration_code": registration.qrcode,
+ "qrcode": registration.qrcode,
+ "qrcode_img_content": registration.qrcode_img_content,
+ "interval": registration.interval,
+ },
+ )
+ .__dict__
+ )
+
+ if action == "poll":
+ qrcode = str(
+ payload.get("qrcode") or payload.get("registration_code") or "",
+ ).strip()
+ if not qrcode:
+ return Response().error("Missing qrcode").__dict__, 400
+ result = await poll_weixin_oc_login_once(
+ platform_config=platform_config,
+ qrcode=qrcode,
+ )
+ if result.get("status") == "created":
+ result["platform_id_suffix"] = _random_platform_id_suffix()
+ return Response().ok(result).__dict__
+
+ return Response().error(f"Unsupported action: {action}").__dict__, 400
diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py
index 1093022e3..73a6995b7 100644
--- a/astrbot/dashboard/routes/plugin.py
+++ b/astrbot/dashboard/routes/plugin.py
@@ -201,7 +201,8 @@ class PluginRoute(Route):
async def get_plugin_page_bridge_sdk(self):
if not await aio_ospath.isfile(str(_PLUGIN_PAGE_BRIDGE_FILE)):
return await self._plugin_page_error_response(
- 404, "Plugin Page bridge SDK not found"
+ 404,
+ "Plugin Page bridge SDK not found",
)
bridge_js = await self._read_plugin_page_text(_PLUGIN_PAGE_BRIDGE_FILE)
initial_context = self._get_plugin_page_initial_context()
@@ -211,9 +212,10 @@ class PluginRoute(Route):
f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n"
)
response = cast(
- QuartResponse,
+ "QuartResponse",
await make_response(
- bridge_js, {"Content-Type": "application/javascript; charset=utf-8"}
+ bridge_js,
+ {"Content-Type": "application/javascript; charset=utf-8"},
),
)
return self._apply_plugin_page_security_headers(response)
@@ -346,7 +348,7 @@ class PluginRoute(Route):
base_dir = Path(
self.plugin_manager.reserved_plugin_path
if plugin.reserved
- else self.plugin_manager.plugin_store_path
+ else self.plugin_manager.plugin_store_path,
).resolve(strict=False)
plugin_root = (base_dir / plugin.root_dir_name).resolve(strict=False)
plugin_root.relative_to(base_dir)
@@ -393,7 +395,7 @@ class PluginRoute(Route):
name=page_name,
title=page_name,
entry_file=_PLUGIN_PAGE_ENTRY_FILE_NAME,
- )
+ ),
)
return pages
@@ -454,7 +456,7 @@ class PluginRoute(Route):
"mailto:",
"tel:",
"blob:",
- )
+ ),
):
return False
return True
@@ -500,7 +502,7 @@ class PluginRoute(Route):
path,
query,
original_fragment,
- )
+ ),
)
@staticmethod
@@ -539,7 +541,7 @@ class PluginRoute(Route):
"/api/plugin/page/bridge-sdk.js",
query,
"",
- )
+ ),
)
@staticmethod
@@ -608,7 +610,9 @@ class PluginRoute(Route):
bridge_tag = f''
if "