fix: recover failed Feishu streaming updates

Feishu CardKit content updates can fail without raising, leaving a blank Generating card while the final streamed response skips normal send. Reopen streaming mode and retry once, close blank cards when the first update fails, and fall back to a regular card when final updates still fail.
This commit is contained in:
chengyongru
2026-06-18 00:02:54 +08:00
committed by Xubin Ren
parent 8ecc2d69c4
commit 4219911423
2 changed files with 128 additions and 24 deletions
+74 -22
View File
@@ -1420,16 +1420,11 @@ class FeishuChannel(BaseChannel):
self.logger.warning("Error stream-updating card {}: {}", card_id, e)
return False
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
"""Turn off CardKit streaming_mode so the chat list preview exits the streaming placeholder.
Per Feishu docs, streaming cards keep a generating-style summary in the session list until
streaming_mode is set to false via card settings (after final content update).
Sequence must strictly exceed the previous card OpenAPI operation on this entity.
"""
def _set_streaming_mode_sync(self, card_id: str, enabled: bool, sequence: int) -> bool:
"""Set CardKit streaming_mode using a strictly increasing sequence."""
from lark_oapi.api.cardkit.v1 import SettingsCardRequest, SettingsCardRequestBody
settings_payload = json.dumps({"config": {"streaming_mode": False}}, ensure_ascii=False)
settings_payload = json.dumps({"config": {"streaming_mode": enabled}}, ensure_ascii=False)
try:
request = (
SettingsCardRequest.builder()
@@ -1446,7 +1441,8 @@ class FeishuChannel(BaseChannel):
response = self._client.cardkit.v1.card.settings(request)
if not response.success():
self.logger.warning(
"Failed to close streaming on card {}: code={}, msg={}",
"Failed to set streaming={} on card {}: code={}, msg={}",
enabled,
card_id,
response.code,
response.msg,
@@ -1454,9 +1450,32 @@ class FeishuChannel(BaseChannel):
return False
return True
except Exception as e:
self.logger.warning("Error closing streaming on card {}: {}", card_id, e)
self.logger.warning("Error setting streaming={} on card {}: {}", enabled, card_id, e)
return False
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
"""Turn off CardKit streaming_mode so the chat list preview exits the streaming placeholder.
Per Feishu docs, streaming cards keep a generating-style summary in the session list until
streaming_mode is set to false via card settings (after final content update).
Sequence must strictly exceed the previous card OpenAPI operation on this entity.
"""
return self._set_streaming_mode_sync(card_id, False, sequence)
def _stream_update_text_with_reopen_sync(
self,
card_id: str,
content: str,
sequence: int,
) -> tuple[bool, int]:
if self._stream_update_text_sync(card_id, content, sequence):
return True, sequence
sequence += 1
if not self._set_streaming_mode_sync(card_id, True, sequence):
return False, sequence
sequence += 1
return self._stream_update_text_sync(card_id, content, sequence), sequence
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
@@ -1499,22 +1518,37 @@ class FeishuChannel(BaseChannel):
# back to sending a regular interactive card.
if buf.card_id:
buf.sequence += 1
ok = await loop.run_in_executor(
ok, buf.sequence = await loop.run_in_executor(
None,
self._stream_update_text_sync,
self._stream_update_text_with_reopen_sync,
buf.card_id,
buf.text,
buf.sequence,
)
if ok:
buf.sequence += 1
await loop.run_in_executor(
closed = await loop.run_in_executor(
None,
self._close_streaming_mode_sync,
buf.card_id,
buf.sequence,
)
if not closed:
buf.sequence += 1
await loop.run_in_executor(
None,
self._close_streaming_mode_sync,
buf.card_id,
buf.sequence,
)
return
buf.sequence += 1
await loop.run_in_executor(
None,
self._close_streaming_mode_sync,
buf.card_id,
buf.sequence,
)
self.logger.warning(
"Streaming card {} final update failed, falling back to regular card",
buf.card_id,
@@ -1567,18 +1601,36 @@ class FeishuChannel(BaseChannel):
),
)
if card_id:
buf.card_id = card_id
buf.sequence = 1
await loop.run_in_executor(
None, self._stream_update_text_sync, card_id, buf.text, 1
ok, sequence = await loop.run_in_executor(
None, self._stream_update_text_with_reopen_sync, card_id, buf.text, 1
)
buf.last_edit = now
if ok:
buf.card_id = card_id
buf.sequence = sequence
buf.last_edit = now
else:
await loop.run_in_executor(
None, self._close_streaming_mode_sync, card_id, sequence + 1
)
elif (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL:
buf.sequence += 1
await loop.run_in_executor(
None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence
ok, buf.sequence = await loop.run_in_executor(
None,
self._stream_update_text_with_reopen_sync,
buf.card_id,
buf.text,
buf.sequence + 1,
)
buf.last_edit = now
if ok:
buf.last_edit = now
else:
buf.sequence += 1
await loop.run_in_executor(
None,
self._close_streaming_mode_sync,
buf.card_id,
buf.sequence,
)
buf.card_id = None
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu, including media (images/files) if present."""
+54 -2
View File
@@ -113,6 +113,22 @@ class TestCloseStreamingMode:
assert ch._close_streaming_mode_sync("card_1", 10) is False
class TestStreamUpdateWithReopen:
def test_reopens_streaming_mode_and_retries_update(self):
ch = _make_channel()
ch._client.cardkit.v1.card_element.content.side_effect = [
_mock_content_response(False),
_mock_content_response(True),
]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
assert ch._stream_update_text_with_reopen_sync("card_1", "hello", 4) == (True, 6)
assert ch._client.cardkit.v1.card_element.content.call_count == 2
settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0]
assert settings_call.body.sequence == 5
assert '"streaming_mode": true' in settings_call.body.settings
class TestStreamUpdateText:
def test_returns_true_on_success(self):
ch = _make_channel()
@@ -149,6 +165,24 @@ class TestSendDelta:
ch._client.im.v1.message.create.assert_called_once()
ch._client.cardkit.v1.card_element.content.assert_called_once()
@pytest.mark.asyncio
async def test_first_delta_closes_blank_card_when_initial_update_fails(self):
ch = _make_channel()
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(False)
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "Hello ")
buf = ch._stream_bufs["oc_chat1"]
assert buf.text == "Hello "
assert buf.card_id is None
assert ch._client.cardkit.v1.card_element.content.call_count == 2
assert ch._client.cardkit.v1.card.settings.call_count == 2
close_call = ch._client.cardkit.v1.card.settings.call_args_list[-1][0][0]
assert '"streaming_mode": false' in close_call.body.settings
@pytest.mark.asyncio
async def test_group_delta_uses_create_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
@@ -338,11 +372,29 @@ class TestSendDelta:
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
# Should NOT attempt to close streaming mode since update failed
ch._client.cardkit.v1.card.settings.assert_not_called()
assert ch._client.cardkit.v1.card.settings.call_count == 2
# Should fall back to sending a regular interactive card
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_reopens_streaming_card_before_fallback(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Recovered content", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.side_effect = [
_mock_content_response(False),
_mock_content_response(True),
]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card_element.content.call_count == 2
assert ch._client.cardkit.v1.card.settings.call_count == 2
ch._client.im.v1.message.create.assert_not_called()
@pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self):
ch = _make_channel()