Compare commits

...

2 Commits

Author SHA1 Message Date
Weilong Liao
a619988d2d chore: bump version to 4.26.2 (#9052) 2026-06-27 17:50:04 +08:00
Weilong Liao
de572e3fe0 fix: avoid duplicate send_message_to_user replies (#9051) 2026-06-27 16:57:17 +08:00
6 changed files with 229 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import logging
__version__ = "4.26.1"
__version__ = "4.26.2"
logger = logging.getLogger("astrbot")

View File

@@ -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)}",

View File

@@ -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
View 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)

View File

@@ -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" }

View File

@@ -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