fix: Slack MAX_MESSAGE_LENGTH + typing indicator via assistant.threads.setStatus
- Increase MAX_MESSAGE_LENGTH from 3,900 to 39,000 (Slack API allows 40k) - Implement real typing indicator using assistant.threads.setStatus API - Shows 'BotName is thinking...' next to the bot name in threads - Auto-clears when the bot sends a reply - Requires assistant:write or chat:write scope - Falls back silently if scope unavailable (reactions still work) - 4 new tests for typing indicator
This commit is contained in:
parent
df07baedfe
commit
319e6615c3
2 changed files with 69 additions and 6 deletions
|
|
@ -66,7 +66,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
- Typing indicators (not natively supported by Slack bots)
|
- Typing indicators (not natively supported by Slack bots)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MAX_MESSAGE_LENGTH = 3900 # Slack hard limit is 4000 but leave room for mrkdwn
|
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
||||||
|
|
||||||
def __init__(self, config: PlatformConfig):
|
def __init__(self, config: PlatformConfig):
|
||||||
super().__init__(config, Platform.SLACK)
|
super().__init__(config, Platform.SLACK)
|
||||||
|
|
@ -216,12 +216,32 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
return SendResult(success=False, error=str(e))
|
return SendResult(success=False, error=str(e))
|
||||||
|
|
||||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||||
"""Slack doesn't support typing indicators for bot users.
|
"""Show a typing/status indicator using assistant.threads.setStatus.
|
||||||
|
|
||||||
The reactions system (👀 on receipt, ✅ on completion) serves as
|
Displays "is thinking..." next to the bot name in a thread.
|
||||||
the visual feedback mechanism instead.
|
Requires the assistant:write or chat:write scope.
|
||||||
|
Auto-clears when the bot sends a reply to the thread.
|
||||||
"""
|
"""
|
||||||
pass
|
if not self._app:
|
||||||
|
return
|
||||||
|
|
||||||
|
thread_ts = None
|
||||||
|
if metadata:
|
||||||
|
thread_ts = metadata.get("thread_id") or metadata.get("thread_ts")
|
||||||
|
|
||||||
|
if not thread_ts:
|
||||||
|
return # Can only set status in a thread context
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._app.client.assistant_threads_setStatus(
|
||||||
|
channel_id=chat_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
status="is thinking...",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Silently ignore — may lack assistant:write scope or not be
|
||||||
|
# in an assistant-enabled context. Falls back to reactions.
|
||||||
|
logger.debug("[Slack] assistant.threads.setStatus failed: %s", e)
|
||||||
|
|
||||||
def _resolve_thread_ts(
|
def _resolve_thread_ts(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -532,6 +532,49 @@ class TestMessageRouting:
|
||||||
adapter.handle_message.assert_not_called()
|
adapter.handle_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestSendTyping — assistant.threads.setStatus
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendTyping:
|
||||||
|
"""Test typing indicator via assistant.threads.setStatus."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sets_status_in_thread(self, adapter):
|
||||||
|
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||||
|
await adapter.send_typing("C123", metadata={"thread_id": "parent_ts"})
|
||||||
|
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
|
||||||
|
channel_id="C123",
|
||||||
|
thread_ts="parent_ts",
|
||||||
|
status="is thinking...",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_noop_without_thread(self, adapter):
|
||||||
|
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||||
|
await adapter.send_typing("C123")
|
||||||
|
adapter._app.client.assistant_threads_setStatus.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_missing_scope_gracefully(self, adapter):
|
||||||
|
adapter._app.client.assistant_threads_setStatus = AsyncMock(
|
||||||
|
side_effect=Exception("missing_scope")
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
await adapter.send_typing("C123", metadata={"thread_id": "ts1"})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_thread_ts_fallback(self, adapter):
|
||||||
|
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||||
|
await adapter.send_typing("C123", metadata={"thread_ts": "fallback_ts"})
|
||||||
|
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
|
||||||
|
channel_id="C123",
|
||||||
|
thread_ts="fallback_ts",
|
||||||
|
status="is thinking...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TestFormatMessage — Markdown → mrkdwn conversion
|
# TestFormatMessage — Markdown → mrkdwn conversion
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -760,7 +803,7 @@ class TestMessageSplitting:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_long_message_split_into_chunks(self, adapter):
|
async def test_long_message_split_into_chunks(self, adapter):
|
||||||
"""Messages over MAX_MESSAGE_LENGTH should be split."""
|
"""Messages over MAX_MESSAGE_LENGTH should be split."""
|
||||||
long_text = "x" * 5000
|
long_text = "x" * 45000 # Over Slack's 40k API limit
|
||||||
adapter._app.client.chat_postMessage = AsyncMock(
|
adapter._app.client.chat_postMessage = AsyncMock(
|
||||||
return_value={"ts": "ts1"}
|
return_value={"ts": "ts1"}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue