支持微信/WhatsApp 回送附件直发并补齐链路测试。

保持入站到 agent 流程不变,在回送阶段支持 media_path/media_url 与 base64 附件直发,同时让通道回包沿用会话中助手附件记录以对齐 chat 历史逻辑。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-29 10:13:09 +08:00
parent 25180921e7
commit 1d46356d9e
8 changed files with 255 additions and 7 deletions

View file

@ -1,6 +1,12 @@
from __future__ import annotations
from oclaw.runtime.application.gateway.inbound_service import _should_suppress_channel_reply
from dataclasses import dataclass
from oclaw.runtime.application.gateway.inbound_service import (
_collect_reply_attachments_from_history,
_parse_message_attachments,
_should_suppress_channel_reply,
)
def test_should_suppress_weixin_openai_missing_api_key_message() -> None:
@ -13,3 +19,35 @@ def test_should_not_suppress_non_weixin_channel() -> None:
text = '⚠️ Missing API key for provider "openai". Configure the gateway auth for that provider, then try again.'
assert _should_suppress_channel_reply(channel="admin_chat", text=text) is False
def test_parse_message_attachments_accepts_json_string() -> None:
out = _parse_message_attachments('[{"type":"image_ref","attachment_id":"a1"}]')
assert len(out) == 1
assert out[0].get("attachment_id") == "a1"
@dataclass
class _Row:
role: str
content: str
attachments: object
class _FakeStore:
def __init__(self, rows: list[_Row]) -> None:
self._rows = rows
def get_messages(self, *, session_id: str, limit: int = 120) -> list[_Row]:
_ = (session_id, limit)
return list(self._rows)
def test_collect_reply_attachments_prefers_matching_assistant_text() -> None:
rows = [
_Row(role="assistant", content="old", attachments='[{"attachment_id":"old"}]'),
_Row(role="assistant", content="target", attachments='[{"attachment_id":"new"}]'),
]
out = _collect_reply_attachments_from_history(store=_FakeStore(rows), session_id="s1", reply_text="target")
assert len(out) == 1
assert out[0].get("attachment_id") == "new"

View file

@ -92,6 +92,14 @@ class WeixinIlinkApiTests(unittest.TestCase):
{
"chat_id": str(payload.get("chat_id") or ""),
"text": f"native:{text}",
"attachments": [
{
"type": "image_ref",
"name": "demo.png",
"mime_type": "image/png",
"data_base64": "aGVsbG8=",
}
],
}
],
}
@ -118,6 +126,9 @@ class WeixinIlinkApiTests(unittest.TestCase):
replies = data.get("replies") if isinstance(data.get("replies"), list) else []
self.assertEqual(len(replies), 1, data)
self.assertEqual(str((replies[0] or {}).get("text") or ""), "native:hello native")
atts = (replies[0] or {}).get("attachments") if isinstance((replies[0] or {}).get("attachments"), list) else []
self.assertEqual(len(atts), 1, data)
self.assertEqual(str((atts[0] or {}).get("name") or ""), "demo.png")
finally:
weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment]
@ -134,6 +145,14 @@ class WeixinIlinkApiTests(unittest.TestCase):
{
"chat_id": str(payload.get("chat_id") or ""),
"text": f"wa:{text}",
"attachments": [
{
"type": "binary_ref",
"name": "demo.txt",
"mime_type": "text/plain",
"data_base64": "d2E=",
}
],
}
],
}
@ -158,6 +177,9 @@ class WeixinIlinkApiTests(unittest.TestCase):
replies = data.get("replies") if isinstance(data.get("replies"), list) else []
self.assertEqual(len(replies), 1, data)
self.assertEqual(str((replies[0] or {}).get("text") or ""), "wa:hello whatsapp")
atts = (replies[0] or {}).get("attachments") if isinstance((replies[0] or {}).get("attachments"), list) else []
self.assertEqual(len(atts), 1, data)
self.assertEqual(str((atts[0] or {}).get("name") or ""), "demo.txt")
finally:
weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment]

View file

@ -49,3 +49,11 @@ def test_official_runner_logs_active_bridge_path() -> None:
text = _read("runtime/operations/weixin_bridge/official_runner.ts")
assert "official runner started account=" in text
assert "native reply failed; no fallback enabled" in text
def test_official_runner_supports_reply_attachments_base64() -> None:
text = _read("runtime/operations/weixin_bridge/official_runner.ts")
assert "_decodeReplyBase64Attachment" in text
assert "data_base64" in text
assert "media_base64" in text
assert "reply.attachments" in text

View file

@ -15,7 +15,23 @@ def test_inbound_whatsapp_accepts_basic_payload_and_returns_replies() -> None:
assert str(payload.get("account_id") or "") == "wa-default"
assert str(payload.get("user_id") or "") == "111@s.whatsapp.net"
assert str(payload.get("chat_id") or "") == "111@s.whatsapp.net"
return {"ok": True, "replies": [{"chat_id": payload.get("chat_id"), "text": "ok"}]}
return {
"ok": True,
"replies": [
{
"chat_id": payload.get("chat_id"),
"text": "ok",
"attachments": [
{
"type": "binary_ref",
"name": "hello.txt",
"mime_type": "text/plain",
"data_base64": "aGVsbG8=",
}
],
}
],
}
try:
fastapi_app.process_inbound_payload_usecase = _fake_usecase # type: ignore[assignment]
@ -35,6 +51,9 @@ def test_inbound_whatsapp_accepts_basic_payload_and_returns_replies() -> None:
replies = data.get("replies") if isinstance(data.get("replies"), list) else []
assert replies and isinstance(replies[0], dict)
assert str(replies[0].get("text") or "") == "ok"
atts = replies[0].get("attachments") if isinstance(replies[0].get("attachments"), list) else []
assert len(atts) == 1
assert str(atts[0].get("name") or "") == "hello.txt"
finally:
fastapi_app.process_inbound_payload_usecase = old_usecase # type: ignore[assignment]

View file

@ -37,3 +37,12 @@ def test_start_all_gracefully_skips_missing_channel_sidecars() -> None:
text = _read("runtime/operations/scripts/start_all.ps1")
assert 'Warn "weixin sidecar skipped:' in text
assert 'Warn "whatsapp sidecar skipped:' in text
def test_whatsapp_runner_supports_reply_attachments_base64() -> None:
text = _read("runtime/operations/whatsapp_bridge/baileys_runner.ts")
assert "sendReplyWithAttachments" in text
assert "data_base64" in text
assert "media_base64" in text
assert "media_path" in text
assert "media_url" in text