mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(dingtalk): AI Cards streaming, emoji reactions, and media handling
Cherry-picked from #10985 by pedh, adapted to current main: * Keeps main's full group-chat gating (require_mention + allowed_users + free_response_chats + mention_patterns) — PR's simpler subset dropped. * Keeps main's fire-and-forget process() dispatch + session_webhook fallback for SDK >= 0.24. * Picks up PR's REQUIRES_EDIT_FINALIZE capability flag on BasePlatformAdapter + finalize kwarg on edit_message(), plumbed through stream_consumer. Default False so Telegram/Slack/Discord/Matrix stay on the zero-overhead fast path. * DingTalk AI Card lifecycle: per-chat _message_contexts, two-card flow (tool-progress + final response) with sibling auto-close driven by reply_to, idempotent 🤔Thinking → 🥳Done swap, $alibabacloud-dingtalk$ for media URL resolution (replaces raw HTTP that was 403-ing). * pyproject: dingtalk extra now dingtalk-stream>=0.20,<1 + alibabacloud-dingtalk>=2.0.0 + qrcode. Closes #10991 Co-authored-by: pedh
This commit is contained in:
parent
d7ef562a05
commit
4459913f40
7 changed files with 1482 additions and 82 deletions
|
|
@ -1045,16 +1045,40 @@ class BasePlatformAdapter(ABC):
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Default: the adapter treats ``finalize=True`` on edit_message as a
|
||||||
|
# no-op and is happy to have the stream consumer skip redundant final
|
||||||
|
# edits. Subclasses that *require* an explicit finalize call to close
|
||||||
|
# out the message lifecycle (e.g. rich card / AI assistant surfaces
|
||||||
|
# such as DingTalk AI Cards) override this to True (class attribute or
|
||||||
|
# property) so the stream consumer knows not to short-circuit.
|
||||||
|
REQUIRES_EDIT_FINALIZE: bool = False
|
||||||
|
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
|
*,
|
||||||
|
finalize: bool = False,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""
|
"""
|
||||||
Edit a previously sent message. Optional — platforms that don't
|
Edit a previously sent message. Optional — platforms that don't
|
||||||
support editing return success=False and callers fall back to
|
support editing return success=False and callers fall back to
|
||||||
sending a new message.
|
sending a new message.
|
||||||
|
|
||||||
|
``finalize`` signals that this is the last edit in a streaming
|
||||||
|
sequence. Most platforms (Telegram, Slack, Discord, Matrix,
|
||||||
|
etc.) treat it as a no-op because their edit APIs have no notion
|
||||||
|
of message lifecycle state — an edit is an edit. Platforms that
|
||||||
|
render streaming updates with a distinct "in progress" state and
|
||||||
|
require explicit closure (e.g. rich card / AI assistant surfaces
|
||||||
|
such as DingTalk AI Cards) use it to finalize the message and
|
||||||
|
transition the UI out of the streaming indicator — those should
|
||||||
|
also set ``REQUIRES_EDIT_FINALIZE = True`` so callers route a
|
||||||
|
final edit through even when content is unchanged. Callers
|
||||||
|
should set ``finalize=True`` on the final edit of a streamed
|
||||||
|
response (typically when ``got_done`` fires in the stream
|
||||||
|
consumer) and leave it ``False`` on intermediate edits.
|
||||||
"""
|
"""
|
||||||
return SendResult(success=False, error="Not supported")
|
return SendResult(success=False, error="Not supported")
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -100,6 +100,14 @@ class GatewayStreamConsumer:
|
||||||
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
||||||
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
||||||
self._final_response_sent = False
|
self._final_response_sent = False
|
||||||
|
# Cache adapter lifecycle capability: only platforms that need an
|
||||||
|
# explicit finalize call (e.g. DingTalk AI Cards) force us to make
|
||||||
|
# a redundant final edit. Everyone else keeps the fast path.
|
||||||
|
# Use ``is True`` (not ``bool(...)``) so MagicMock attribute access
|
||||||
|
# in tests doesn't incorrectly enable this path.
|
||||||
|
self._adapter_requires_finalize: bool = (
|
||||||
|
getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True
|
||||||
|
)
|
||||||
|
|
||||||
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
|
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
|
||||||
self._in_think_block = False
|
self._in_think_block = False
|
||||||
|
|
@ -361,7 +369,16 @@ class GatewayStreamConsumer:
|
||||||
if not got_done and not got_segment_break and commentary_text is None:
|
if not got_done and not got_segment_break and commentary_text is None:
|
||||||
display_text += self.cfg.cursor
|
display_text += self.cfg.cursor
|
||||||
|
|
||||||
current_update_visible = await self._send_or_edit(display_text)
|
# Segment break: finalize the current message so platforms
|
||||||
|
# that need explicit closure (e.g. DingTalk AI Cards) don't
|
||||||
|
# leave the previous segment stuck in a loading state when
|
||||||
|
# the next segment (tool progress, next chunk) creates a
|
||||||
|
# new message below it. got_done has its own finalize
|
||||||
|
# path below so we don't finalize here for it.
|
||||||
|
current_update_visible = await self._send_or_edit(
|
||||||
|
display_text,
|
||||||
|
finalize=got_segment_break,
|
||||||
|
)
|
||||||
self._last_edit_time = time.monotonic()
|
self._last_edit_time = time.monotonic()
|
||||||
|
|
||||||
if got_done:
|
if got_done:
|
||||||
|
|
@ -372,10 +389,22 @@ class GatewayStreamConsumer:
|
||||||
if self._accumulated:
|
if self._accumulated:
|
||||||
if self._fallback_final_send:
|
if self._fallback_final_send:
|
||||||
await self._send_fallback_final(self._accumulated)
|
await self._send_fallback_final(self._accumulated)
|
||||||
elif current_update_visible:
|
elif (
|
||||||
|
current_update_visible
|
||||||
|
and not self._adapter_requires_finalize
|
||||||
|
):
|
||||||
|
# Mid-stream edit above already delivered the
|
||||||
|
# final accumulated content. Skip the redundant
|
||||||
|
# final edit — but only for adapters that don't
|
||||||
|
# need an explicit finalize signal.
|
||||||
self._final_response_sent = True
|
self._final_response_sent = True
|
||||||
elif self._message_id:
|
elif self._message_id:
|
||||||
self._final_response_sent = await self._send_or_edit(self._accumulated)
|
# Either the mid-stream edit didn't run (no
|
||||||
|
# visible update this tick) OR the adapter needs
|
||||||
|
# explicit finalize=True to close the stream.
|
||||||
|
self._final_response_sent = await self._send_or_edit(
|
||||||
|
self._accumulated, finalize=True,
|
||||||
|
)
|
||||||
elif not self._already_sent:
|
elif not self._already_sent:
|
||||||
self._final_response_sent = await self._send_or_edit(self._accumulated)
|
self._final_response_sent = await self._send_or_edit(self._accumulated)
|
||||||
return
|
return
|
||||||
|
|
@ -633,12 +662,15 @@ class GatewayStreamConsumer:
|
||||||
logger.error("Commentary send error: %s", e)
|
logger.error("Commentary send error: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _send_or_edit(self, text: str) -> bool:
|
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
|
||||||
"""Send or edit the streaming message.
|
"""Send or edit the streaming message.
|
||||||
|
|
||||||
Returns True if the text was successfully delivered (sent or edited),
|
Returns True if the text was successfully delivered (sent or edited),
|
||||||
False otherwise. Callers like the overflow split loop use this to
|
False otherwise. Callers like the overflow split loop use this to
|
||||||
decide whether to advance past the delivered chunk.
|
decide whether to advance past the delivered chunk.
|
||||||
|
|
||||||
|
``finalize`` is True when this is the last edit in a streaming
|
||||||
|
sequence.
|
||||||
"""
|
"""
|
||||||
# Strip MEDIA: directives so they don't appear as visible text.
|
# Strip MEDIA: directives so they don't appear as visible text.
|
||||||
# Media files are delivered as native attachments after the stream
|
# Media files are delivered as native attachments after the stream
|
||||||
|
|
@ -672,14 +704,22 @@ class GatewayStreamConsumer:
|
||||||
try:
|
try:
|
||||||
if self._message_id is not None:
|
if self._message_id is not None:
|
||||||
if self._edit_supported:
|
if self._edit_supported:
|
||||||
# Skip if text is identical to what we last sent
|
# Skip if text is identical to what we last sent.
|
||||||
if text == self._last_sent_text:
|
# Exception: adapters that require an explicit finalize
|
||||||
|
# call (REQUIRES_EDIT_FINALIZE) must still receive the
|
||||||
|
# finalize=True edit even when content is unchanged, so
|
||||||
|
# their streaming UI can transition out of the in-
|
||||||
|
# progress state. Everyone else short-circuits.
|
||||||
|
if text == self._last_sent_text and not (
|
||||||
|
finalize and self._adapter_requires_finalize
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
# Edit existing message
|
# Edit existing message
|
||||||
result = await self.adapter.edit_message(
|
result = await self.adapter.edit_message(
|
||||||
chat_id=self.chat_id,
|
chat_id=self.chat_id,
|
||||||
message_id=self._message_id,
|
message_id=self._message_id,
|
||||||
content=text,
|
content=text,
|
||||||
|
finalize=finalize,
|
||||||
)
|
)
|
||||||
if result.success:
|
if result.success:
|
||||||
self._already_sent = True
|
self._already_sent = True
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ termux = [
|
||||||
"hermes-agent[honcho]",
|
"hermes-agent[honcho]",
|
||||||
"hermes-agent[acp]",
|
"hermes-agent[acp]",
|
||||||
]
|
]
|
||||||
dingtalk = ["dingtalk-stream>=0.1.0,<1", "qrcode>=7.0,<8"]
|
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
|
||||||
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
|
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
|
||||||
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||||
rl = [
|
rl = [
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ class TestSend:
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
mock_client.post = AsyncMock(return_value=mock_response)
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
adapter._http_client = mock_client
|
adapter._http_client = mock_client
|
||||||
adapter._session_webhooks["chat-123"] = "https://cached.example/webhook"
|
adapter._session_webhooks["chat-123"] = ("https://cached.example/webhook", 9999999999999)
|
||||||
|
|
||||||
result = await adapter.send("chat-123", "Hello!")
|
result = await adapter.send("chat-123", "Hello!")
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
|
|
@ -681,3 +681,290 @@ class TestIncomingHandlerProcess:
|
||||||
processing_gate.set()
|
processing_gate.set()
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Text extraction — mention preservation + platform sanity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractTextMentions:
|
||||||
|
|
||||||
|
def test_preserves_at_mentions_in_text(self):
|
||||||
|
"""@mentions are routing signals (via isInAtList), not text to strip.
|
||||||
|
|
||||||
|
Stripping all @handles collateral-damages emails, SSH URLs, and
|
||||||
|
literal references the user wrote.
|
||||||
|
"""
|
||||||
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||||
|
cases = [
|
||||||
|
("@bot hello", "@bot hello"),
|
||||||
|
("contact alice@example.com", "contact alice@example.com"),
|
||||||
|
("git@github.com:foo/bar.git", "git@github.com:foo/bar.git"),
|
||||||
|
("what does @openai think", "what does @openai think"),
|
||||||
|
("@机器人 转发给 @老王", "@机器人 转发给 @老王"),
|
||||||
|
]
|
||||||
|
for text, expected in cases:
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.text = text
|
||||||
|
msg.rich_text = None
|
||||||
|
msg.rich_text_content = None
|
||||||
|
assert DingTalkAdapter._extract_text(msg) == expected, (
|
||||||
|
f"mangled: {text!r} -> {DingTalkAdapter._extract_text(msg)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dingtalk_in_platform_enum(self):
|
||||||
|
assert Platform.DINGTALK.value == "dingtalk"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Concurrency — chat-scoped message context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageContextIsolation:
|
||||||
|
|
||||||
|
def test_contexts_keyed_by_chat_id(self):
|
||||||
|
"""Two concurrent chats must not clobber each other's context."""
|
||||||
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||||
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||||
|
|
||||||
|
msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A")
|
||||||
|
msg_b = MagicMock(conversation_id="chat-B", sender_staff_id="user-B")
|
||||||
|
adapter._message_contexts["chat-A"] = msg_a
|
||||||
|
adapter._message_contexts["chat-B"] = msg_b
|
||||||
|
|
||||||
|
assert adapter._message_contexts["chat-A"] is msg_a
|
||||||
|
assert adapter._message_contexts["chat-B"] is msg_b
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Card lifecycle: finalize via metadata["streaming"]
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCardLifecycle:
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter_with_card(self):
|
||||||
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||||
|
a = DingTalkAdapter(PlatformConfig(
|
||||||
|
enabled=True,
|
||||||
|
extra={"card_template_id": "tmpl-1"},
|
||||||
|
))
|
||||||
|
a._card_sdk = MagicMock()
|
||||||
|
a._card_sdk.create_card_with_options_async = AsyncMock()
|
||||||
|
a._card_sdk.deliver_card_with_options_async = AsyncMock()
|
||||||
|
a._card_sdk.streaming_update_with_options_async = AsyncMock()
|
||||||
|
a._http_client = AsyncMock()
|
||||||
|
a._get_access_token = AsyncMock(return_value="token")
|
||||||
|
# Minimal message context
|
||||||
|
msg = MagicMock(
|
||||||
|
conversation_id="chat-1",
|
||||||
|
conversation_type="1",
|
||||||
|
sender_staff_id="staff-1",
|
||||||
|
message_id="user-msg-1",
|
||||||
|
)
|
||||||
|
a._message_contexts["chat-1"] = msg
|
||||||
|
a._session_webhooks["chat-1"] = (
|
||||||
|
"https://api.dingtalk.com/x", 9999999999999,
|
||||||
|
)
|
||||||
|
return a
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_final_reply_finalizes_card(self, adapter_with_card):
|
||||||
|
"""send(reply_to=...) creates a closed card (final response path)."""
|
||||||
|
a = adapter_with_card
|
||||||
|
result = await a.send("chat-1", "Hello", reply_to="user-msg-1")
|
||||||
|
assert result.success
|
||||||
|
call = a._card_sdk.streaming_update_with_options_async.call_args
|
||||||
|
assert call[0][0].is_finalize is True
|
||||||
|
# Not tracked as streaming — it's already closed.
|
||||||
|
assert "chat-1" not in a._streaming_cards
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_intermediate_send_stays_streaming(self, adapter_with_card):
|
||||||
|
"""send() without reply_to creates an OPEN card (tool progress /
|
||||||
|
commentary / streaming first chunk). No flicker closed→streaming
|
||||||
|
when edit_message follows."""
|
||||||
|
a = adapter_with_card
|
||||||
|
result = await a.send("chat-1", "💻 terminal: ls")
|
||||||
|
assert result.success
|
||||||
|
call = a._card_sdk.streaming_update_with_options_async.call_args
|
||||||
|
assert call[0][0].is_finalize is False
|
||||||
|
# Tracked for sibling cleanup.
|
||||||
|
assert result.message_id in a._streaming_cards.get("chat-1", {})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_done_fires_only_when_reply_to_is_set(self, adapter_with_card):
|
||||||
|
"""reply_to distinguishes final response (base.py) from tool-progress
|
||||||
|
sends (run.py). Done must only fire for the former."""
|
||||||
|
a = adapter_with_card
|
||||||
|
fired: list[str] = []
|
||||||
|
a._fire_done_reaction = lambda cid: fired.append(cid)
|
||||||
|
|
||||||
|
# Tool-progress / commentary path: no reply_to — no Done.
|
||||||
|
await a.send("chat-1", "tool line")
|
||||||
|
assert fired == []
|
||||||
|
|
||||||
|
# Final response path: reply_to set — Done fires.
|
||||||
|
await a.send("chat-1", "final", reply_to="user-msg-1")
|
||||||
|
assert fired == ["chat-1"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_message_finalize_fires_done(self, adapter_with_card):
|
||||||
|
"""Stream consumer's final edit_message(finalize=True) fires Done."""
|
||||||
|
a = adapter_with_card
|
||||||
|
fired: list[str] = []
|
||||||
|
a._fire_done_reaction = lambda cid: fired.append(cid)
|
||||||
|
|
||||||
|
await a.send("chat-1", "initial")
|
||||||
|
# Reopen via edit_message(finalize=False) then close.
|
||||||
|
await a.edit_message(
|
||||||
|
chat_id="chat-1", message_id="track-X",
|
||||||
|
content="streaming...", finalize=False,
|
||||||
|
)
|
||||||
|
await a.edit_message(
|
||||||
|
chat_id="chat-1", message_id="track-X",
|
||||||
|
content="final", finalize=True,
|
||||||
|
)
|
||||||
|
assert "chat-1" in fired
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_message_finalize_false_tracks_sibling(self, adapter_with_card):
|
||||||
|
"""After edit_message(finalize=False), card is tracked as open."""
|
||||||
|
a = adapter_with_card
|
||||||
|
await a.edit_message(
|
||||||
|
chat_id="chat-1", message_id="track-1",
|
||||||
|
content="partial", finalize=False,
|
||||||
|
)
|
||||||
|
assert "chat-1" in a._streaming_cards
|
||||||
|
assert a._streaming_cards["chat-1"].get("track-1") == "partial"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_next_send_auto_closes_sibling_streaming_cards(
|
||||||
|
self, adapter_with_card,
|
||||||
|
):
|
||||||
|
"""Tool-progress card left open (send without reply_to + edits) must
|
||||||
|
be auto-closed when the final-reply send arrives."""
|
||||||
|
a = adapter_with_card
|
||||||
|
# First tool: intermediate send — card stays open.
|
||||||
|
r1 = await a.send("chat-1", "💻 tool1")
|
||||||
|
# Second tool: edit_message(finalize=False) — keeps streaming.
|
||||||
|
await a.edit_message(
|
||||||
|
chat_id="chat-1", message_id=r1.message_id,
|
||||||
|
content="💻 tool1\n💻 tool2", finalize=False,
|
||||||
|
)
|
||||||
|
assert r1.message_id in a._streaming_cards.get("chat-1", {})
|
||||||
|
a._card_sdk.streaming_update_with_options_async.reset_mock()
|
||||||
|
|
||||||
|
# Final response send auto-closes the sibling.
|
||||||
|
await a.send("chat-1", "final answer", reply_to="user-msg")
|
||||||
|
|
||||||
|
calls = a._card_sdk.streaming_update_with_options_async.call_args_list
|
||||||
|
assert len(calls) >= 2
|
||||||
|
# First call was the sibling close with last-seen tool-progress content.
|
||||||
|
first_req = calls[0][0][0]
|
||||||
|
assert first_req.out_track_id == r1.message_id
|
||||||
|
assert first_req.is_finalize is True
|
||||||
|
assert "tool1" in first_req.content
|
||||||
|
# Streaming tracking is cleared after close.
|
||||||
|
assert "chat-1" not in a._streaming_cards
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_message_requires_message_id(self, adapter_with_card):
|
||||||
|
a = adapter_with_card
|
||||||
|
result = await a.edit_message(
|
||||||
|
chat_id="chat-1", message_id="", content="x", finalize=True,
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
a._card_sdk.streaming_update_with_options_async.assert_not_called()
|
||||||
|
|
||||||
|
def test_fire_done_reaction_is_idempotent(self, adapter_with_card):
|
||||||
|
a = adapter_with_card
|
||||||
|
captured = []
|
||||||
|
def _capture(coro):
|
||||||
|
captured.append(coro)
|
||||||
|
a._spawn_bg = _capture
|
||||||
|
|
||||||
|
a._fire_done_reaction("chat-1")
|
||||||
|
a._fire_done_reaction("chat-1")
|
||||||
|
assert len(captured) == 1
|
||||||
|
captured[0].close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI Card Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDingTalkAdapterAICards:
|
||||||
|
@pytest.fixture
|
||||||
|
def config(self):
|
||||||
|
return PlatformConfig(
|
||||||
|
enabled=True,
|
||||||
|
extra={
|
||||||
|
"client_id": "test_id",
|
||||||
|
"client_secret": "test_secret",
|
||||||
|
"card_template_id": "test_card_template",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_stream_client(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_access_token = MagicMock(return_value="test_token")
|
||||||
|
return client
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_http_client(self):
|
||||||
|
return AsyncMock()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_message(self):
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.message_id = "test_msg_id"
|
||||||
|
msg.conversation_id = "test_conv_id"
|
||||||
|
msg.conversation_type = "1"
|
||||||
|
msg.sender_id = "sender1"
|
||||||
|
msg.sender_nick = "Test User"
|
||||||
|
msg.sender_staff_id = "staff1"
|
||||||
|
msg.text = MagicMock(content="Hello")
|
||||||
|
msg.session_webhook = "https://api.dingtalk.com/robot/sendBySession?session=test"
|
||||||
|
msg.session_webhook_expired_time = 999999999999
|
||||||
|
msg.create_at = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||||||
|
msg.at_users = []
|
||||||
|
return msg
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message):
|
||||||
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||||
|
|
||||||
|
adapter = DingTalkAdapter(config)
|
||||||
|
adapter._stream_client = mock_stream_client
|
||||||
|
adapter._http_client = mock_http_client
|
||||||
|
adapter._message_contexts["test_conv_id"] = mock_message
|
||||||
|
adapter._session_webhooks = {"test_conv_id": ("https://api.dingtalk.com/robot/sendBySession?session=test", 9999999999999)}
|
||||||
|
adapter._card_template_id = "test_card_template"
|
||||||
|
|
||||||
|
# Mock the card SDK with proper async methods
|
||||||
|
mock_card_sdk = MagicMock()
|
||||||
|
mock_card_sdk.create_card_with_options_async = AsyncMock()
|
||||||
|
mock_card_sdk.deliver_card_with_options_async = AsyncMock()
|
||||||
|
mock_card_sdk.streaming_update_with_options_async = AsyncMock()
|
||||||
|
adapter._card_sdk = mock_card_sdk
|
||||||
|
|
||||||
|
# Mock access token
|
||||||
|
adapter._get_access_token = AsyncMock(return_value="test_token")
|
||||||
|
|
||||||
|
result = await adapter.send("test_conv_id", "Hello World")
|
||||||
|
|
||||||
|
mock_card_sdk.create_card_with_options_async.assert_called_once()
|
||||||
|
mock_card_sdk.deliver_card_with_options_async.assert_called_once()
|
||||||
|
mock_card_sdk.streaming_update_with_options_async.assert_called_once()
|
||||||
|
assert result.success is True
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,51 @@ class TestCleanForDisplay:
|
||||||
# ── Integration: _send_or_edit strips MEDIA: ─────────────────────────────
|
# ── Integration: _send_or_edit strips MEDIA: ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinalizeCapabilityGate:
|
||||||
|
"""Verify REQUIRES_EDIT_FINALIZE gates the redundant final edit.
|
||||||
|
|
||||||
|
Platforms that don't need an explicit finalize signal (Telegram,
|
||||||
|
Slack, Matrix, …) should skip the redundant final edit when the
|
||||||
|
mid-stream edit already delivered the final content. Platforms that
|
||||||
|
*do* need it (DingTalk AI Cards) must always receive a finalize=True
|
||||||
|
edit at the end of the stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_identical_text_skip_respects_adapter_flag(self):
|
||||||
|
"""_send_or_edit short-circuits identical-text only when the
|
||||||
|
adapter doesn't require an explicit finalize signal."""
|
||||||
|
# Adapter without finalize requirement — should skip identical edit.
|
||||||
|
plain = MagicMock()
|
||||||
|
plain.REQUIRES_EDIT_FINALIZE = False
|
||||||
|
plain.send = AsyncMock(return_value=SimpleNamespace(
|
||||||
|
success=True, message_id="m1",
|
||||||
|
))
|
||||||
|
plain.edit_message = AsyncMock()
|
||||||
|
plain.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
c1 = GatewayStreamConsumer(plain, "chat_1")
|
||||||
|
await c1._send_or_edit("hello") # first send
|
||||||
|
await c1._send_or_edit("hello", finalize=True) # identical → skip
|
||||||
|
plain.edit_message.assert_not_called()
|
||||||
|
|
||||||
|
# Adapter that requires finalize — must still fire the edit.
|
||||||
|
picky = MagicMock()
|
||||||
|
picky.REQUIRES_EDIT_FINALIZE = True
|
||||||
|
picky.send = AsyncMock(return_value=SimpleNamespace(
|
||||||
|
success=True, message_id="m1",
|
||||||
|
))
|
||||||
|
picky.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||||
|
success=True, message_id="m1",
|
||||||
|
))
|
||||||
|
picky.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
c2 = GatewayStreamConsumer(picky, "chat_1")
|
||||||
|
await c2._send_or_edit("hello")
|
||||||
|
await c2._send_or_edit("hello", finalize=True)
|
||||||
|
# Finalize edit must go through even on identical content.
|
||||||
|
picky.edit_message.assert_called_once()
|
||||||
|
assert picky.edit_message.call_args[1]["finalize"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestSendOrEditMediaStripping:
|
class TestSendOrEditMediaStripping:
|
||||||
"""Verify _send_or_edit strips MEDIA: before sending to the platform."""
|
"""Verify _send_or_edit strips MEDIA: before sending to the platform."""
|
||||||
|
|
||||||
|
|
|
||||||
169
uv.lock
generated
169
uv.lock
generated
|
|
@ -174,6 +174,120 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-credentials"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiofiles" },
|
||||||
|
{ name = "alibabacloud-credentials-api" },
|
||||||
|
{ name = "alibabacloud-tea" },
|
||||||
|
{ name = "apscheduler" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d2/15/2b01b4a6cbed4cc2c8a1c801efec43af945af22fd3ca5f78c932117fd4ce/alibabacloud_credentials-1.0.8.tar.gz", hash = "sha256:364c22abef2d240b259ceadf1ce6800017f19a336729553956928a1edd12e769", size = 40465, upload-time = "2026-03-11T09:13:59.398Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/24/7c47501b24897a1379cd57cc8b8de376161f2487548fc8233b2b74ab25c7/alibabacloud_credentials-1.0.8-py3-none-any.whl", hash = "sha256:66677c3fa54aeb66cfb9cc97da4a787534f38a04d09bbfa0bc6c815fe1af7e28", size = 48799, upload-time = "2026-03-11T09:13:58.113Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-credentials-api"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-dingtalk"
|
||||||
|
version = "2.2.42"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alibabacloud-endpoint-util" },
|
||||||
|
{ name = "alibabacloud-gateway-dingtalk" },
|
||||||
|
{ name = "alibabacloud-gateway-spi" },
|
||||||
|
{ name = "alibabacloud-openapi-util" },
|
||||||
|
{ name = "alibabacloud-tea-openapi" },
|
||||||
|
{ name = "alibabacloud-tea-util" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/66/36efc03a2a8ed16c2ce176fd5ab6ff9725d0048aef33eaf867e85e625401/alibabacloud_dingtalk-2.2.42.tar.gz", hash = "sha256:220b1d52f5ef82a23ea625d3c8a91a733a685417248e217cf5aa30fe0b3a8978", size = 2023797, upload-time = "2026-04-10T03:58:28.143Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/80/7d1c1438e17c1fc90d037f1b73debe3fc2dfa348eb91e12818c2584d1865/alibabacloud_dingtalk-2.2.42-py3-none-any.whl", hash = "sha256:5f5c2ef3351b7926eb870af11089e14f802e4caa51d5f72920ad79a67f03d3e4", size = 2142688, upload-time = "2026-04-10T03:58:26.33Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-endpoint-util"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-gateway-dingtalk"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alibabacloud-gateway-spi" },
|
||||||
|
{ name = "alibabacloud-tea-util" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d2/40/751d8bdf133d7fcf053f10c98e8e506810e7bee06458a02eaaa14d30ac26/alibabacloud_gateway_dingtalk-1.0.2.tar.gz", hash = "sha256:acea8b0b1d11e0394913f0b0899ddd19c0bfceab716060449b57fcc250ceb300", size = 2938, upload-time = "2023-04-25T09:48:42.249Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-gateway-spi"
|
||||||
|
version = "0.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alibabacloud-credentials" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf72ee9bea60bbbeff2bc42cdfe24d2544db52bc517e1a/alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b", size = 4249, upload-time = "2025-02-23T16:29:54.222Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-openapi-util"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alibabacloud-tea-util" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/51/be5802851a4ed20ac2c6db50ac8354a6e431e93db6e714ca39b50983626f/alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34", size = 7981, upload-time = "2026-01-15T08:05:03.947Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/46/9b217343648b366eb93447f5d93116e09a61956005794aed5ef95a2e9e2e/alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd", size = 7661, upload-time = "2026-01-15T08:05:01.374Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-tea"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-tea-openapi"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alibabacloud-credentials" },
|
||||||
|
{ name = "alibabacloud-gateway-spi" },
|
||||||
|
{ name = "alibabacloud-tea-util" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "darabonba-core" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alibabacloud-tea-util"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alibabacloud-tea" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/ee/ea90be94ad781a5055db29556744681fc71190ef444ae53adba45e1be5f3/alibabacloud_tea_util-0.3.14.tar.gz", hash = "sha256:708e7c9f64641a3c9e0e566365d2f23675f8d7c2a3e2971d9402ceede0408cdb", size = 7515, upload-time = "2025-11-19T06:01:08.504Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "altair"
|
name = "altair"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
|
|
@ -249,6 +363,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apscheduler"
|
||||||
|
version = "3.11.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzlocal" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
|
|
@ -860,6 +986,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darabonba-core"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "alibabacloud-tea" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "datasets"
|
name = "datasets"
|
||||||
version = "4.8.4"
|
version = "4.8.4"
|
||||||
|
|
@ -1699,7 +1838,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermes-agent"
|
name = "hermes-agent"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
|
|
@ -1730,6 +1869,7 @@ all = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiosqlite", marker = "sys_platform == 'linux'" },
|
{ name = "aiosqlite", marker = "sys_platform == 'linux'" },
|
||||||
|
{ name = "alibabacloud-dingtalk" },
|
||||||
{ name = "asyncpg", marker = "sys_platform == 'linux'" },
|
{ name = "asyncpg", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
{ name = "daytona" },
|
{ name = "daytona" },
|
||||||
|
|
@ -1737,6 +1877,7 @@ all = [
|
||||||
{ name = "dingtalk-stream" },
|
{ name = "dingtalk-stream" },
|
||||||
{ name = "discord-py", extra = ["voice"] },
|
{ name = "discord-py", extra = ["voice"] },
|
||||||
{ name = "elevenlabs" },
|
{ name = "elevenlabs" },
|
||||||
|
{ name = "fastapi" },
|
||||||
{ name = "faster-whisper" },
|
{ name = "faster-whisper" },
|
||||||
{ name = "honcho-ai" },
|
{ name = "honcho-ai" },
|
||||||
{ name = "lark-oapi" },
|
{ name = "lark-oapi" },
|
||||||
|
|
@ -1756,6 +1897,7 @@ all = [
|
||||||
{ name = "slack-bolt" },
|
{ name = "slack-bolt" },
|
||||||
{ name = "slack-sdk" },
|
{ name = "slack-sdk" },
|
||||||
{ name = "sounddevice" },
|
{ name = "sounddevice" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
cli = [
|
cli = [
|
||||||
{ name = "simple-term-menu" },
|
{ name = "simple-term-menu" },
|
||||||
|
|
@ -1774,6 +1916,7 @@ dev = [
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
dingtalk = [
|
dingtalk = [
|
||||||
|
{ name = "alibabacloud-dingtalk" },
|
||||||
{ name = "dingtalk-stream" },
|
{ name = "dingtalk-stream" },
|
||||||
]
|
]
|
||||||
feishu = [
|
feishu = [
|
||||||
|
|
@ -1842,6 +1985,10 @@ voice = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "sounddevice" },
|
{ name = "sounddevice" },
|
||||||
]
|
]
|
||||||
|
web = [
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
yc-bench = [
|
yc-bench = [
|
||||||
{ name = "yc-bench", marker = "python_full_version >= '3.12'" },
|
{ name = "yc-bench", marker = "python_full_version >= '3.12'" },
|
||||||
]
|
]
|
||||||
|
|
@ -1853,19 +2000,21 @@ requires-dist = [
|
||||||
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
|
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
|
||||||
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
|
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
|
||||||
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
|
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
|
||||||
|
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" },
|
||||||
{ name = "anthropic", specifier = ">=0.39.0,<1" },
|
{ name = "anthropic", specifier = ">=0.39.0,<1" },
|
||||||
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
|
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
|
||||||
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" },
|
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" },
|
||||||
{ name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" },
|
{ name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" },
|
||||||
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
|
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
|
||||||
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" },
|
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" },
|
||||||
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.1.0,<1" },
|
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" },
|
||||||
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" },
|
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" },
|
||||||
{ name = "edge-tts", specifier = ">=7.2.7,<8" },
|
{ name = "edge-tts", specifier = ">=7.2.7,<8" },
|
||||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
||||||
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
||||||
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
||||||
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
||||||
|
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
|
||||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
||||||
{ name = "fire", specifier = ">=0.7.1,<1" },
|
{ name = "fire", specifier = ">=0.7.1,<1" },
|
||||||
{ name = "firecrawl-py", specifier = ">=4.16.0,<5" },
|
{ name = "firecrawl-py", specifier = ">=4.16.0,<5" },
|
||||||
|
|
@ -1894,6 +2043,7 @@ requires-dist = [
|
||||||
{ name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" },
|
{ name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" },
|
{ name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" },
|
{ name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" },
|
||||||
|
{ name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" },
|
||||||
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" },
|
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" },
|
||||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" },
|
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.5,<4" },
|
{ name = "jinja2", specifier = ">=3.1.5,<4" },
|
||||||
|
|
@ -1929,10 +2079,11 @@ requires-dist = [
|
||||||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
||||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" },
|
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
|
||||||
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
||||||
{ name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" },
|
{ name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" },
|
||||||
]
|
]
|
||||||
provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "rl", "yc-bench", "all"]
|
provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "web", "rl", "yc-bench", "all"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hf-transfer"
|
name = "hf-transfer"
|
||||||
|
|
@ -4950,6 +5101,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzlocal"
|
||||||
|
version = "5.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unpaddedbase64"
|
name = "unpaddedbase64"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue