mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-10 06:50:18 +08:00
Compare commits
2 Commits
codex/fix-
...
v4.26.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a619988d2d | ||
|
|
de572e3fe0 |
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
|
||||
__version__ = "4.26.1"
|
||||
__version__ = "4.26.2"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -179,6 +179,29 @@ class RespondStage(Stage):
|
||||
if result.result_content_type == ResultContentType.STREAMING_FINISH:
|
||||
event.set_extra("_streaming_finished", True)
|
||||
return
|
||||
sent_plain_texts = event.get_extra(
|
||||
"_send_message_to_user_current_session_plain_texts",
|
||||
[],
|
||||
)
|
||||
result_plain_text = result.get_plain_text().strip()
|
||||
if (
|
||||
result_plain_text
|
||||
and isinstance(sent_plain_texts, list)
|
||||
and result_plain_text in sent_plain_texts
|
||||
and all(
|
||||
comp.type
|
||||
in {
|
||||
ComponentType.Plain,
|
||||
ComponentType.Reply,
|
||||
ComponentType.At,
|
||||
}
|
||||
for comp in result.chain
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
"send_message_to_user already delivered the same text in this session, skip respond stage to avoid duplicate reply.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Prepare to send - {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}",
|
||||
|
||||
@@ -317,10 +317,23 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
else:
|
||||
return f"error: invalid session: {session}"
|
||||
|
||||
await context.context.context.send_message(
|
||||
target_session,
|
||||
MessageChain(chain=components),
|
||||
)
|
||||
message_chain = MessageChain(chain=components)
|
||||
await context.context.context.send_message(target_session, message_chain)
|
||||
if str(target_session) == current_session:
|
||||
context.context.event._has_send_oper = True
|
||||
sent_plain_text = message_chain.get_plain_text().strip()
|
||||
if sent_plain_text:
|
||||
sent_plain_texts = context.context.event.get_extra(
|
||||
"_send_message_to_user_current_session_plain_texts",
|
||||
[],
|
||||
)
|
||||
if not isinstance(sent_plain_texts, list):
|
||||
sent_plain_texts = []
|
||||
sent_plain_texts.append(sent_plain_text)
|
||||
context.context.event.set_extra(
|
||||
"_send_message_to_user_current_session_plain_texts",
|
||||
sent_plain_texts,
|
||||
)
|
||||
return f"Message sent to session {target_session}"
|
||||
|
||||
|
||||
|
||||
61
changelogs/v4.26.2.md
Normal file
61
changelogs/v4.26.2.md
Normal file
@@ -0,0 +1,61 @@
|
||||
## What's Changed
|
||||
|
||||
### Fixes
|
||||
|
||||
- Preserve image formats and JPEG quality during media conversion (#9019, #9031)
|
||||
- Fix `DashboardRequest` compatibility for dashboard requests, resolving some IM webhook errors (#9021, #9023)
|
||||
- Normalize streamed message whitespace and strip trailing buffers before sending (#9029)
|
||||
- Prevent plugin detail marketplace mismatches (#9028)
|
||||
- Reliably kill shell process trees on Windows timeout (#8822)
|
||||
- Guard `_KeyRotator` index bounds (#9040)
|
||||
- Track plugin install source for update checks (#9037)
|
||||
- Handle MiMo STT audio and reasoning output (#8938)
|
||||
- Only show plugin updates for newer market versions
|
||||
- Sanitize orphaned `tool_result` blocks in the Anthropic provider (#8952)
|
||||
- Preserve assistant messages that contain `reasoning_content` without content or tool calls (#8483)
|
||||
- Recognize DeepSeek V4 proxy model names with substring matching (#9015)
|
||||
- Clear KV storage when uninstalling plugins and update related i18n text (#8291)
|
||||
- Separate plugin and tool activation state (#9048)
|
||||
- Keep Tab navigation within reset-password inputs in the account dialog (#9049)
|
||||
- Align OpenAI tool message sanitization (#8350)
|
||||
- Avoid duplicate `send_message_to_user` replies (#9051)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update the Python requirement to 3.12 (#9022)
|
||||
- Add the Spanish README (#9020)
|
||||
|
||||
### Chores
|
||||
|
||||
- Remove the plugin publish issue template (#9050)
|
||||
|
||||
## 中文翻译
|
||||
|
||||
### 修复
|
||||
|
||||
- 在媒体转换过程中保留图片格式和 JPEG 质量 (#9019, #9031)
|
||||
- 修复 `DashboardRequest` 对仪表盘请求的兼容性,解决部分 IM webhook 报错问题 (#9021, #9023)
|
||||
- 统一流式消息片段中的空白字符处理,并在发送前移除尾部缓存 (#9029)
|
||||
- 防止插件详情与插件市场信息不匹配 (#9028)
|
||||
- 在 Windows 超时场景下可靠终止 shell 进程树 (#8822)
|
||||
- 修复 `_KeyRotator` 索引边界检查 (#9040)
|
||||
- 跟踪插件安装来源,用于更新检查 (#9037)
|
||||
- 处理 MiMo STT 音频和推理输出 (#8938)
|
||||
- 仅在插件市场版本更新时显示插件更新提示
|
||||
- 清理 Anthropic 提供商中的孤立 `tool_result` 块 (#8952)
|
||||
- 保留只包含 `reasoning_content`、但没有内容或工具调用的 assistant 消息 (#8483)
|
||||
- 通过子字符串匹配识别 DeepSeek V4 代理模型名称 (#9015)
|
||||
- 卸载插件时清理 KV 存储,并更新相关 i18n 文案 (#8291)
|
||||
- 分离插件和工具的启用状态 (#9048)
|
||||
- 在账号对话框中,将 Tab 导航限制在重置密码输入框内 (#9049)
|
||||
- 对齐 OpenAI 工具消息清理逻辑 (#8350)
|
||||
- 避免重复发送 `send_message_to_user` 回复 (#9051)
|
||||
|
||||
### 文档
|
||||
|
||||
- 将 Python 版本要求更新为 3.12 (#9022)
|
||||
- 添加西班牙语 README (#9020)
|
||||
|
||||
### 杂项
|
||||
|
||||
- 移除插件发布 issue 模板 (#9050)
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.1"
|
||||
version = "4.26.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.pipeline.respond.stage import RespondStage
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
|
||||
|
||||
@@ -21,13 +23,18 @@ def _make_context(
|
||||
"computer_use_runtime": runtime,
|
||||
}
|
||||
}
|
||||
extras = {}
|
||||
event = SimpleNamespace(
|
||||
unified_msg_origin=current_session,
|
||||
role=role,
|
||||
_has_send_oper=False,
|
||||
get_sender_id=lambda: "user-1",
|
||||
)
|
||||
event.set_extra = lambda key, value: extras.__setitem__(key, value)
|
||||
event.get_extra = lambda key, default=None: extras.get(key, default)
|
||||
return SimpleNamespace(
|
||||
context=SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
unified_msg_origin=current_session,
|
||||
role=role,
|
||||
get_sender_id=lambda: "user-1",
|
||||
),
|
||||
event=event,
|
||||
context=SimpleNamespace(
|
||||
get_config=lambda umo: cfg,
|
||||
send_message=AsyncMock(),
|
||||
@@ -36,6 +43,65 @@ def _make_context(
|
||||
)
|
||||
|
||||
|
||||
class _DummyRespondEvent:
|
||||
def __init__(self, result_text: str, sent_plain_texts: list[str]) -> None:
|
||||
self._extras = {
|
||||
"_send_message_to_user_current_session_plain_texts": sent_plain_texts,
|
||||
}
|
||||
self._result = MessageEventResult().message(result_text)
|
||||
self.send = AsyncMock()
|
||||
self.plugins_name = []
|
||||
|
||||
def get_result(self):
|
||||
"""Return the current message result."""
|
||||
return self._result
|
||||
|
||||
def set_extra(self, key, value) -> None:
|
||||
"""Set pipeline extra data."""
|
||||
self._extras[key] = value
|
||||
|
||||
def get_extra(self, key, default=None):
|
||||
"""Get pipeline extra data."""
|
||||
return self._extras.get(key, default)
|
||||
|
||||
def get_sender_name(self) -> str:
|
||||
"""Return a sender name for respond-stage logging."""
|
||||
return "tester"
|
||||
|
||||
def get_sender_id(self) -> str:
|
||||
"""Return a sender ID for respond-stage logging."""
|
||||
return "user-1"
|
||||
|
||||
def get_platform_id(self) -> str:
|
||||
"""Return a platform ID for respond-stage logging."""
|
||||
return "test"
|
||||
|
||||
def get_platform_name(self) -> str:
|
||||
"""Return a platform name for segmented-reply checks."""
|
||||
return "test"
|
||||
|
||||
def _outline_chain(self, chain) -> str:
|
||||
"""Return a readable outline for respond-stage logging."""
|
||||
return " ".join(comp.text for comp in chain if hasattr(comp, "text"))
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
"""Return whether this dummy event has stopped."""
|
||||
return False
|
||||
|
||||
def clear_result(self) -> None:
|
||||
"""Clear the current message result."""
|
||||
self._result = None
|
||||
|
||||
|
||||
def _make_respond_stage() -> RespondStage:
|
||||
"""Build a minimally initialized RespondStage for unit tests."""
|
||||
stage = RespondStage()
|
||||
stage.config = {"provider_settings": {}}
|
||||
stage.platform_settings = {"path_mapping": []}
|
||||
stage.enable_seg = False
|
||||
return stage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_with_full_three_part_session():
|
||||
"""LLM passes a complete three-part session string."""
|
||||
@@ -81,6 +147,61 @@ async def test_send_message_defaults_to_current_session():
|
||||
call_args = ctx.context.context.send_message.call_args
|
||||
target_session = call_args[0][0]
|
||||
assert str(target_session) == "feishu:GroupMessage:oc_xxx"
|
||||
assert ctx.context.event._has_send_oper is True
|
||||
assert ctx.context.event.get_extra(
|
||||
"_send_message_to_user_current_session_plain_texts",
|
||||
) == ["hello"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_other_session_does_not_record_current_text():
|
||||
"""Messages sent to another session do not affect current-session dedupe."""
|
||||
tool = SendMessageToUserTool()
|
||||
ctx = _make_context(current_session="feishu:GroupMessage:oc_xxx")
|
||||
result = await tool.call(
|
||||
ctx,
|
||||
messages=[{"type": "plain", "text": "hello"}],
|
||||
session="feishu:GroupMessage:oc_other",
|
||||
)
|
||||
assert "Message sent to session" in result
|
||||
assert ctx.context.event._has_send_oper is False
|
||||
assert (
|
||||
ctx.context.event.get_extra(
|
||||
"_send_message_to_user_current_session_plain_texts",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_stage_skips_same_text_after_send_message_to_user():
|
||||
"""RespondStage skips only when the tool already sent the same text."""
|
||||
stage = _make_respond_stage()
|
||||
event = _DummyRespondEvent(
|
||||
result_text="duplicate reply",
|
||||
sent_plain_texts=["duplicate reply"],
|
||||
)
|
||||
|
||||
result = await stage.process(event)
|
||||
|
||||
assert result is None
|
||||
event.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_stage_sends_different_text_after_send_message_to_user():
|
||||
"""RespondStage still sends a distinct completion after the tool call."""
|
||||
stage = _make_respond_stage()
|
||||
event = _DummyRespondEvent(
|
||||
result_text="I have sent the message with the tool.",
|
||||
sent_plain_texts=["duplicate reply"],
|
||||
)
|
||||
|
||||
result = await stage.process(event)
|
||||
|
||||
assert result is None
|
||||
event.send.assert_awaited_once()
|
||||
assert event.get_result() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user