mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
fix(feishu): receive post attachments from the top-level files array (#8190)
* fix(feishu): keep attachments sent with post messages * fix(feishu): keep user-typed file placeholders and malformed files arrays from losing attachments * test(feishu): pin mixed inline attachment behavior and consume the post file count * fix(feishu): parse post commands before adding attachment placeholders --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Totoro <msy626836554@gmail.com>
This commit is contained in:
parent
b16e197618
commit
bce5dc3327
@ -60,6 +60,9 @@ make test-blocking-io # channels share the Gate
|
||||
`/start <code>`) is handled before `allowed_users` on purpose, so that a new user
|
||||
can bind. The code is the secret: show it only in the browser that requested it
|
||||
and never echo it to the chat.
|
||||
- **Feishu post commands use the caption.** Add placeholders for top-level post
|
||||
attachments after command detection. For commands that enter the chat path,
|
||||
`receive_file` adds their placeholders after the manager parses the arguments.
|
||||
- **One active owner per external identity.** A partial unique index in the
|
||||
database enforces this. Application code alone cannot, because two workers can
|
||||
bind at the same time.
|
||||
|
||||
@ -55,6 +55,9 @@ PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
|
||||
FEISHU_STOP_TIMEOUT_SECONDS = 5
|
||||
FEISHU_WS_CLOSE_TIMEOUT_SECONDS = 2
|
||||
_PENDING_CARD_METADATA_KEY = "_feishu_pending_card_id"
|
||||
# Number of attachments from a post's top-level ``files`` array.
|
||||
_POST_FILES_COUNT_METADATA_KEY = "_feishu_post_files_count"
|
||||
_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY = "_feishu_defer_post_file_placeholders"
|
||||
# Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop,
|
||||
# which also carries the SDK's websocket reads, pongs and its 120s ping loop:
|
||||
# every second the bridge blocks that loop is a second the connection cannot
|
||||
@ -107,6 +110,7 @@ class _ParsedMessage:
|
||||
chat_type: str | None
|
||||
text: str
|
||||
files: list[dict[str, Any]]
|
||||
post_file_count: int = 0
|
||||
msg_type: InboundMessageType = InboundMessageType.CHAT
|
||||
|
||||
|
||||
@ -242,6 +246,12 @@ def _is_feishu_command(text: str) -> bool:
|
||||
return is_known_channel_command(text)
|
||||
|
||||
|
||||
def _append_post_file_placeholders(text: str, count: int) -> str:
|
||||
paragraphs = [text] if text else []
|
||||
paragraphs.extend(["[file]"] * count)
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
|
||||
class FeishuChannel(Channel):
|
||||
"""Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client.
|
||||
|
||||
@ -809,6 +819,10 @@ class FeishuChannel(Channel):
|
||||
logger.warning("[Feishu] received message with no files: %s", msg)
|
||||
return msg
|
||||
text = msg.text
|
||||
post_file_count = msg.metadata.pop(_POST_FILES_COUNT_METADATA_KEY, 0)
|
||||
defer_post_file_placeholders = msg.metadata.pop(_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY, False)
|
||||
if not isinstance(post_file_count, int) or post_file_count < 0:
|
||||
post_file_count = 0
|
||||
search_from = 0
|
||||
|
||||
def replace_next_placeholder(placeholder: str, replacement: str) -> None:
|
||||
@ -819,13 +833,36 @@ class FeishuChannel(Channel):
|
||||
text = f"{text[:idx]}{replacement}{text[idx + len(placeholder) :]}"
|
||||
search_from = idx + len(replacement)
|
||||
|
||||
downloaded: list[tuple[str, str]] = []
|
||||
for file in files:
|
||||
if file.get("image_key"):
|
||||
virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id)
|
||||
replace_next_placeholder("[image]", virtual_path)
|
||||
downloaded.append(("[image]", virtual_path))
|
||||
elif file.get("file_key"):
|
||||
virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id)
|
||||
replace_next_placeholder("[file]", virtual_path)
|
||||
downloaded.append(("[file]", virtual_path))
|
||||
|
||||
# Top-level post attachments append their ``[file]`` placeholders
|
||||
# after the parsed body, so their slots are the final
|
||||
# ``post_file_count`` ones. Fill them from the end first: a literal
|
||||
# ``[file]`` typed by the user must not consume a top-level
|
||||
# attachment's path. Inline rich-text attachments keep the historical
|
||||
# left-to-right matching below; that path is deliberately unchanged.
|
||||
post_file_count = min(post_file_count, len(downloaded))
|
||||
if defer_post_file_placeholders is True:
|
||||
# A command such as /goal has now entered the chat path. Add the
|
||||
# generated slots to the chat text after its arguments were parsed.
|
||||
text = _append_post_file_placeholders(text, post_file_count)
|
||||
suffix_end = len(text)
|
||||
for placeholder, virtual_path in reversed(downloaded[len(downloaded) - post_file_count :] if post_file_count else []):
|
||||
idx = text.rfind(placeholder, 0, suffix_end)
|
||||
if idx < 0:
|
||||
continue
|
||||
text = f"{text[:idx]}{virtual_path}{text[idx + len(placeholder) :]}"
|
||||
suffix_end = idx
|
||||
|
||||
for placeholder, virtual_path in downloaded[: len(downloaded) - post_file_count]:
|
||||
replace_next_placeholder(placeholder, virtual_path)
|
||||
msg.text = text
|
||||
# Clear the descriptors so the generic URL-based _ingest_inbound_files
|
||||
# pass does not try to re-fetch files that were downloaded by key.
|
||||
@ -1750,6 +1787,10 @@ class FeishuChannel(Channel):
|
||||
}
|
||||
if source_preview:
|
||||
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
|
||||
if parsed.post_file_count:
|
||||
metadata[_POST_FILES_COUNT_METADATA_KEY] = parsed.post_file_count
|
||||
if parsed.msg_type == InboundMessageType.COMMAND:
|
||||
metadata[_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY] = True
|
||||
if routing.clarification_card_id:
|
||||
metadata[_PENDING_CARD_METADATA_KEY] = routing.clarification_card_id
|
||||
|
||||
@ -2088,6 +2129,26 @@ class FeishuChannel(Channel):
|
||||
text = ""
|
||||
text = text.strip()
|
||||
|
||||
# Feishu desktop attaches files to a message as a post whose rich-text
|
||||
# body holds the caption and whose top-level ``files`` array holds the
|
||||
# attachments. The body may be empty. Keep these attachments last so
|
||||
# ``receive_file`` can match their placeholders from the end. Skip
|
||||
# folders and attachments already represented by a rich-text element.
|
||||
attachments = content.get("files") if isinstance(content, dict) else None
|
||||
if not isinstance(attachments, list):
|
||||
attachments = []
|
||||
post_file_count = 0
|
||||
for attachment in attachments:
|
||||
if not isinstance(attachment, dict) or attachment.get("is_folder"):
|
||||
continue
|
||||
file_key = attachment.get("file_key")
|
||||
if not isinstance(file_key, str) or not file_key:
|
||||
continue
|
||||
if any(existing.get("file_key") == file_key for existing in files_list):
|
||||
continue
|
||||
files_list.append({"file_key": file_key})
|
||||
post_file_count += 1
|
||||
|
||||
logger.info(
|
||||
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d",
|
||||
chat_id,
|
||||
@ -2113,6 +2174,7 @@ class FeishuChannel(Channel):
|
||||
chat_type=chat_type,
|
||||
text=text,
|
||||
files=files_list,
|
||||
post_file_count=post_file_count,
|
||||
)
|
||||
|
||||
def _on_message(self, event) -> None:
|
||||
@ -2155,6 +2217,8 @@ class FeishuChannel(Channel):
|
||||
if _is_feishu_command(command_text):
|
||||
parsed.msg_type = InboundMessageType.COMMAND
|
||||
parsed.text = command_text
|
||||
elif parsed.post_file_count:
|
||||
parsed.text = _append_post_file_placeholders(text, parsed.post_file_count)
|
||||
|
||||
# topic_id determines which LangGraph thread the message maps to.
|
||||
# P2P chats: topic_id=None so all messages share one thread (like Telegram DMs).
|
||||
|
||||
@ -19,6 +19,7 @@ from app.channels.message_bus import (
|
||||
PENDING_CLARIFICATION_METADATA_KEY,
|
||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
|
||||
InboundMessage,
|
||||
InboundMessageType,
|
||||
MessageBus,
|
||||
OutboundMessage,
|
||||
)
|
||||
@ -1220,6 +1221,305 @@ def test_feishu_store_bridge_refuses_to_block_the_gateway_loop_itself(caplog):
|
||||
_run(go())
|
||||
|
||||
|
||||
def _make_post_event(content: dict, *, message_id: str = "msg_post"):
|
||||
event = MagicMock()
|
||||
event.event.message.chat_id = "chat_1"
|
||||
event.event.message.message_id = message_id
|
||||
event.event.message.root_id = None
|
||||
event.event.message.parent_id = None
|
||||
event.event.message.thread_id = None
|
||||
event.event.sender.sender_id.open_id = "user_1"
|
||||
event.event.message.content = json.dumps(content)
|
||||
return event
|
||||
|
||||
|
||||
def test_feishu_post_with_text_and_top_level_files_extracts_attachments():
|
||||
"""Feishu desktop attaches files to a post message in a top-level ``files`` array."""
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [[{"tag": "text", "text": "帮我核对这两张发票和采购订单,看看有没有差异。", "style": []}]],
|
||||
"content_v2": [[{"tag": "text", "text": "帮我核对这两张发票和采购订单,看看有没有差异。", "style": []}]],
|
||||
"files": [
|
||||
{"file_key": "file_inv_a", "file_name": "INV-A-20260815.csv", "is_folder": False},
|
||||
{"file_key": "file_inv_b", "file_name": "INV-B-20260903.csv", "is_folder": False},
|
||||
{"file_key": "file_po", "file_name": "PO-2026-08.csv", "is_folder": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
mock_make_inbound.assert_called_once()
|
||||
assert mock_make_inbound.call_args[1]["text"] == "帮我核对这两张发票和采购订单,看看有没有差异。\n\n[file]\n\n[file]\n\n[file]"
|
||||
assert mock_make_inbound.call_args[1]["files"] == [{"file_key": "file_inv_a"}, {"file_key": "file_inv_b"}, {"file_key": "file_po"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["bind-code", None])
|
||||
def test_feishu_post_connect_reads_only_the_caption(monkeypatch, code):
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test", "connection_repo": object()})
|
||||
channel._main_loop = MagicMock()
|
||||
bind = MagicMock()
|
||||
submit = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(channel, "_bind_connection_from_connect_code", bind)
|
||||
monkeypatch.setattr(channel, "_submit_threadsafe_coroutine", submit)
|
||||
monkeypatch.setattr(channel, "_schedule_prepare_inbound", MagicMock())
|
||||
caption = f"@_user_1 /connect {code}" if code else "@_user_1 /connect"
|
||||
event = _make_post_event({"content": [[{"tag": "text", "text": caption}]], "files": [{"file_key": "file_a"}]})
|
||||
|
||||
channel._on_message(event)
|
||||
|
||||
if code:
|
||||
bind.assert_called_once()
|
||||
assert bind.call_args.kwargs["code"] == code
|
||||
submit.assert_called_once()
|
||||
else:
|
||||
bind.assert_not_called()
|
||||
submit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("argument", "operation"), [("clear", "delete"), ("compare [file]", "put")])
|
||||
async def test_feishu_post_goal_parses_caption_before_loading_files(tmp_path, monkeypatch, argument, operation):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
bus = MessageBus()
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test"})
|
||||
schedule = MagicMock()
|
||||
monkeypatch.setattr(channel, "_schedule_prepare_inbound", schedule)
|
||||
event = _make_post_event(
|
||||
{
|
||||
"content": [[{"tag": "at", "text": "@_user_1"}, {"tag": "text", "text": f"/goal {argument}"}]],
|
||||
"files": [{"file_key": "file_a"}, {"file_key": "file_b"}],
|
||||
}
|
||||
)
|
||||
channel._on_message(event)
|
||||
schedule.assert_called_once()
|
||||
inbound = schedule.call_args.args[1]
|
||||
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||
|
||||
manager = ChannelManager(bus, JsonChannelStore(tmp_path / "bindings.json"))
|
||||
goal_request = AsyncMock()
|
||||
monkeypatch.setattr(manager, "_goal_request", goal_request)
|
||||
monkeypatch.setattr(manager, "_lookup_thread_id", AsyncMock(return_value="thread_1"))
|
||||
download = AsyncMock(side_effect=["/mnt/user-data/uploads/A.csv", "/mnt/user-data/uploads/B.csv"])
|
||||
monkeypatch.setattr(channel, "_receive_single_file", download)
|
||||
|
||||
async def handle_chat(msg, **kwargs):
|
||||
assert msg.text == argument
|
||||
result = await channel.receive_file(msg, "thread_1")
|
||||
assert result.text == f"{argument}\n\n/mnt/user-data/uploads/A.csv\n\n/mnt/user-data/uploads/B.csv"
|
||||
|
||||
chat = AsyncMock(side_effect=handle_chat)
|
||||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||||
await manager._handle_command(inbound)
|
||||
|
||||
goal_request.assert_awaited_once()
|
||||
assert goal_request.await_args.args[:2] == (operation, "thread_1")
|
||||
if operation == "put":
|
||||
assert goal_request.await_args.kwargs["json"] == {"objective": argument}
|
||||
chat.assert_awaited_once()
|
||||
assert download.await_count == 2
|
||||
else:
|
||||
chat.assert_not_awaited()
|
||||
download.assert_not_awaited()
|
||||
|
||||
|
||||
def test_feishu_files_only_post_is_not_treated_as_empty():
|
||||
"""A files-only post has an empty rich-text body; the attachments still make it non-empty."""
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [[]],
|
||||
"content_v2": [[]],
|
||||
"files": [
|
||||
{"file_key": "file_a", "file_name": "A.csv", "is_folder": False},
|
||||
{"file_key": "file_b", "file_name": "B.csv", "is_folder": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
mock_make_inbound.assert_called_once()
|
||||
assert mock_make_inbound.call_args[1]["text"] == "[file]\n\n[file]"
|
||||
assert mock_make_inbound.call_args[1]["files"] == [{"file_key": "file_a"}, {"file_key": "file_b"}]
|
||||
|
||||
|
||||
def test_feishu_post_skips_folders_and_duplicate_attachments():
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [[{"tag": "text", "text": "见附件"}, {"tag": "file", "file_key": "file_a"}]],
|
||||
"files": [
|
||||
{"file_key": "file_a", "file_name": "A.csv", "is_folder": False},
|
||||
{"file_key": "folder_x", "file_name": "资料", "is_folder": True},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
mock_make_inbound.assert_called_once()
|
||||
assert mock_make_inbound.call_args[1]["text"] == "见附件 [file]"
|
||||
assert mock_make_inbound.call_args[1]["files"] == [{"file_key": "file_a"}]
|
||||
|
||||
|
||||
def test_feishu_post_merges_inline_and_top_level_files_in_order():
|
||||
"""Inline rich-text files come first; top-level files append without re-adding duplicates."""
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [[{"tag": "text", "text": "见附件"}, {"tag": "file", "file_key": "file_inline"}]],
|
||||
"files": [
|
||||
{"file_key": "file_inline", "file_name": "inline.csv", "is_folder": False},
|
||||
{"file_key": "file_top1", "file_name": "top1.csv", "is_folder": False},
|
||||
{"file_key": "file_top2", "file_name": "top2.csv", "is_folder": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
mock_make_inbound.assert_called_once()
|
||||
assert mock_make_inbound.call_args[1]["text"] == "见附件 [file]\n\n[file]\n\n[file]"
|
||||
assert mock_make_inbound.call_args[1]["files"] == [
|
||||
{"file_key": "file_inline"},
|
||||
{"file_key": "file_top1"},
|
||||
{"file_key": "file_top2"},
|
||||
]
|
||||
|
||||
|
||||
def test_feishu_post_user_written_file_placeholder_is_not_replaced():
|
||||
"""A literal ``[file]`` typed by the user must not consume an attachment's path."""
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [[{"tag": "text", "text": "请按 [file] 格式整理", "style": []}]],
|
||||
"files": [
|
||||
{"file_key": "file_a", "file_name": "A.csv", "is_folder": False},
|
||||
{"file_key": "file_b", "file_name": "B.csv", "is_folder": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
kwargs = mock_make_inbound.call_args[1]
|
||||
assert kwargs["text"] == "请按 [file] 格式整理\n\n[file]\n\n[file]"
|
||||
assert kwargs["metadata"][feishu_module._POST_FILES_COUNT_METADATA_KEY] == 2
|
||||
|
||||
async def go():
|
||||
msg = InboundMessage(
|
||||
channel_name="feishu",
|
||||
chat_id="chat_1",
|
||||
user_id="user_1",
|
||||
text=kwargs["text"],
|
||||
thread_ts="msg_post",
|
||||
files=kwargs["files"],
|
||||
metadata=kwargs["metadata"],
|
||||
)
|
||||
channel._receive_single_file = AsyncMock(side_effect=["/mnt/user-data/uploads/A.csv", "/mnt/user-data/uploads/B.csv"])
|
||||
result = await channel.receive_file(msg, "thread_1")
|
||||
assert result.text == "请按 [file] 格式整理\n\n/mnt/user-data/uploads/A.csv\n\n/mnt/user-data/uploads/B.csv"
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_post_mixed_inline_and_top_level_keeps_appended_slot_last():
|
||||
"""Pin the mixed inline + top-level behavior: only the appended slot is protected.
|
||||
|
||||
Inline rich-text attachments still match placeholders left to right, exactly as
|
||||
on ``main``, so a user-typed ``[file]`` ahead of an inline attachment is consumed
|
||||
first. This test documents that pre-existing limitation; the fix here only
|
||||
guarantees the appended top-level slot (the last one) is never displaced.
|
||||
"""
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [
|
||||
[{"tag": "text", "text": "见 [file] 整理", "style": []}],
|
||||
[{"tag": "file", "file_key": "file_inline"}],
|
||||
],
|
||||
"files": [{"file_key": "file_top", "file_name": "top.csv", "is_folder": False}],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
kwargs = mock_make_inbound.call_args[1]
|
||||
assert kwargs["text"] == "见 [file] 整理\n\n[file]\n\n[file]"
|
||||
assert kwargs["files"] == [{"file_key": "file_inline"}, {"file_key": "file_top"}]
|
||||
assert kwargs["metadata"][feishu_module._POST_FILES_COUNT_METADATA_KEY] == 1
|
||||
|
||||
async def go():
|
||||
msg = InboundMessage(
|
||||
channel_name="feishu",
|
||||
chat_id="chat_1",
|
||||
user_id="user_1",
|
||||
text=kwargs["text"],
|
||||
thread_ts="msg_post",
|
||||
files=kwargs["files"],
|
||||
metadata=kwargs["metadata"],
|
||||
)
|
||||
channel._receive_single_file = AsyncMock(side_effect=["/mnt/user-data/uploads/inline.csv", "/mnt/user-data/uploads/top.csv"])
|
||||
result = await channel.receive_file(msg, "thread_1")
|
||||
# Pre-existing inline behavior: the user-typed literal is consumed first and
|
||||
# the inline slot keeps its ``[file]``; the appended top-level slot is last.
|
||||
assert result.text == "见 /mnt/user-data/uploads/inline.csv 整理\n\n[file]\n\n/mnt/user-data/uploads/top.csv"
|
||||
# The count is consumed, so it does not ride along on outbound metadata.
|
||||
assert feishu_module._POST_FILES_COUNT_METADATA_KEY not in msg.metadata
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_post_ignores_non_list_files_field():
|
||||
"""A malformed top-level ``files`` value must not take the whole message down."""
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test"})
|
||||
event = _make_post_event(
|
||||
{
|
||||
"title": "",
|
||||
"content": [[{"tag": "text", "text": "先看文字", "style": []}]],
|
||||
"files": 3,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
|
||||
mock_make_inbound.assert_called_once()
|
||||
kwargs = mock_make_inbound.call_args[1]
|
||||
assert kwargs["text"] == "先看文字"
|
||||
assert kwargs["files"] == []
|
||||
assert feishu_module._POST_FILES_COUNT_METADATA_KEY not in kwargs["metadata"]
|
||||
|
||||
|
||||
def _make_text_event(
|
||||
text: str,
|
||||
*,
|
||||
|
||||
@ -55,6 +55,10 @@ become visible only after the complete copy is published.
|
||||
Feishu and DingTalk attachment transfers return standalone temporary sandboxes
|
||||
to the pool after synchronization, without interrupting an active run.
|
||||
|
||||
Feishu desktop posts can include a caption and files. Commands use the caption,
|
||||
and a command that starts a chat turn, such as `/goal <condition>`, passes the
|
||||
files to that turn.
|
||||
|
||||
**Configuration in `config.yaml`:**
|
||||
|
||||
Discord's `channels.discord.allowed_guilds` accepts one positive numeric guild
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user