fix(feishu): scope streaming buffers by message
Keep concurrent Feishu group replies from sharing one streaming card buffer when sessions are split by topic or top-level message. Made-with: Cursor
This commit is contained in:
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
||||||
|
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@@ -22,8 +23,6 @@ from nanobot.channels.base import BaseChannel
|
|||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
|
||||||
|
|
||||||
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||||
|
|
||||||
# Message type display mapping
|
# Message type display mapping
|
||||||
@@ -623,6 +622,12 @@ class FeishuChannel(BaseChannel):
|
|||||||
if len(self._reaction_ids) > 500:
|
if len(self._reaction_ids) > 500:
|
||||||
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stream_key(chat_id: str, metadata: dict[str, Any] | None = None) -> str:
|
||||||
|
"""Scope streaming buffers to the inbound message when available."""
|
||||||
|
meta = metadata or {}
|
||||||
|
return meta.get("message_id") or chat_id
|
||||||
|
|
||||||
# Regex to match markdown tables (header + separator + data rows)
|
# Regex to match markdown tables (header + separator + data rows)
|
||||||
_TABLE_RE = re.compile(
|
_TABLE_RE = re.compile(
|
||||||
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
|
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
|
||||||
@@ -1349,6 +1354,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
if not self._client:
|
if not self._client:
|
||||||
return
|
return
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
|
stream_key = self._stream_key(chat_id, meta)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
||||||
|
|
||||||
@@ -1363,7 +1369,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
if self.config.done_emoji:
|
if self.config.done_emoji:
|
||||||
await self._add_reaction(message_id, self.config.done_emoji)
|
await self._add_reaction(message_id, self.config.done_emoji)
|
||||||
|
|
||||||
buf = self._stream_bufs.pop(chat_id, None)
|
buf = self._stream_bufs.pop(stream_key, None)
|
||||||
if not buf or not buf.text:
|
if not buf or not buf.text:
|
||||||
return
|
return
|
||||||
# Try to finalize via streaming card; if that fails (e.g.
|
# Try to finalize via streaming card; if that fails (e.g.
|
||||||
@@ -1417,10 +1423,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# --- accumulate delta ---
|
# --- accumulate delta ---
|
||||||
buf = self._stream_bufs.get(chat_id)
|
buf = self._stream_bufs.get(stream_key)
|
||||||
if buf is None:
|
if buf is None:
|
||||||
buf = _FeishuStreamBuf()
|
buf = _FeishuStreamBuf()
|
||||||
self._stream_bufs[chat_id] = buf
|
self._stream_bufs[stream_key] = buf
|
||||||
buf.text += delta
|
buf.text += delta
|
||||||
if not buf.text.strip():
|
if not buf.text.strip():
|
||||||
return
|
return
|
||||||
@@ -1469,7 +1475,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
hint = (msg.content or "").strip()
|
hint = (msg.content or "").strip()
|
||||||
if not hint:
|
if not hint:
|
||||||
return
|
return
|
||||||
buf = self._stream_bufs.get(msg.chat_id)
|
buf = self._stream_bufs.get(self._stream_key(msg.chat_id, msg.metadata))
|
||||||
if buf and buf.card_id:
|
if buf and buf.card_id:
|
||||||
# Delegate to send_delta so tool hints get the same
|
# Delegate to send_delta so tool hints get the same
|
||||||
# throttling (and card creation) as regular text deltas.
|
# throttling (and card creation) as regular text deltas.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
|
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -160,6 +160,24 @@ class TestRemoveReactionAsync:
|
|||||||
|
|
||||||
|
|
||||||
class TestStreamEndReactionCleanup:
|
class TestStreamEndReactionCleanup:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_buffers_are_scoped_by_message_id(self):
|
||||||
|
ch = _make_channel()
|
||||||
|
ch._create_streaming_card_sync = MagicMock(return_value=None)
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1", "first",
|
||||||
|
metadata={"message_id": "om_first"},
|
||||||
|
)
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1", "second",
|
||||||
|
metadata={"message_id": "om_second"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ch._stream_bufs["om_first"].text == "first"
|
||||||
|
assert ch._stream_bufs["om_second"].text == "second"
|
||||||
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_removes_reaction_on_stream_end(self):
|
async def test_removes_reaction_on_stream_end(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user