Rebase onto upstream (a4d95fd)
#12
@@ -206,25 +206,89 @@ class TelegramChannel(BaseChannel):
|
|||||||
if msg.media:
|
if msg.media:
|
||||||
await self._send_with_media(chat_id, html_content, msg.media)
|
await self._send_with_media(chat_id, html_content, msg.media)
|
||||||
else:
|
else:
|
||||||
# Text-only message
|
# Text-only message - split if too long
|
||||||
await self._app.bot.send_message(
|
await self._send_text_chunks(chat_id, html_content, parse_mode="HTML")
|
||||||
chat_id=chat_id,
|
|
||||||
text=html_content,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback to plain text if HTML parsing fails
|
# Fallback to plain text if HTML parsing fails
|
||||||
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
|
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
|
||||||
try:
|
try:
|
||||||
await self._app.bot.send_message(
|
await self._send_text_chunks(int(msg.chat_id), msg.content, parse_mode=None)
|
||||||
chat_id=int(msg.chat_id),
|
|
||||||
text=msg.content
|
|
||||||
)
|
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"Error sending Telegram message: {e2}")
|
logger.error(f"Error sending Telegram message: {e2}")
|
||||||
|
|
||||||
|
async def _send_text_chunks(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
text: str,
|
||||||
|
parse_mode: str | None = "HTML"
|
||||||
|
) -> None:
|
||||||
|
"""Split and send long messages at sentence boundaries.
|
||||||
|
|
||||||
|
Telegram has a 4096 character limit per message.
|
||||||
|
Per design doc: split at sentence boundaries, send as multiple messages.
|
||||||
|
"""
|
||||||
|
MAX_LENGTH = 4096
|
||||||
|
|
||||||
|
if len(text) <= MAX_LENGTH:
|
||||||
|
# Single message
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode=parse_mode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Split at sentence boundaries
|
||||||
|
import re
|
||||||
|
# Split on sentence endings: . ! ? followed by space/newline/end
|
||||||
|
sentences = re.split(r'([.!?]+(?:\s+|$))', text)
|
||||||
|
|
||||||
|
# Rejoin sentence with its punctuation
|
||||||
|
parts = []
|
||||||
|
for i in range(0, len(sentences) - 1, 2):
|
||||||
|
parts.append(sentences[i] + (sentences[i+1] if i+1 < len(sentences) else ''))
|
||||||
|
if len(sentences) % 2 == 1: # Last part without punctuation
|
||||||
|
parts.append(sentences[-1])
|
||||||
|
|
||||||
|
# Group into chunks under MAX_LENGTH
|
||||||
|
chunks = []
|
||||||
|
current_chunk = ""
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
# If single part exceeds limit, force split it
|
||||||
|
if len(part) > MAX_LENGTH:
|
||||||
|
if current_chunk:
|
||||||
|
chunks.append(current_chunk)
|
||||||
|
current_chunk = ""
|
||||||
|
# Hard split at MAX_LENGTH
|
||||||
|
for i in range(0, len(part), MAX_LENGTH):
|
||||||
|
chunks.append(part[i:i + MAX_LENGTH])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try adding part to current chunk
|
||||||
|
test_chunk = current_chunk + part
|
||||||
|
if len(test_chunk) > MAX_LENGTH:
|
||||||
|
# Save current chunk, start new one
|
||||||
|
if current_chunk:
|
||||||
|
chunks.append(current_chunk)
|
||||||
|
current_chunk = part
|
||||||
|
else:
|
||||||
|
current_chunk = test_chunk
|
||||||
|
|
||||||
|
# Add final chunk
|
||||||
|
if current_chunk:
|
||||||
|
chunks.append(current_chunk)
|
||||||
|
|
||||||
|
# Send all chunks as separate messages
|
||||||
|
for chunk in chunks:
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=chunk.strip(),
|
||||||
|
parse_mode=parse_mode
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None:
|
async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None:
|
||||||
"""
|
"""
|
||||||
Send message with media attachments.
|
Send message with media attachments.
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Tests for Telegram message chunking.
|
||||||
|
|
||||||
|
Per design doc: messages >4096 chars should split at sentence boundaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_short_message_not_chunked():
|
||||||
|
"""Messages under 4096 chars should send as single message."""
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "test-token"
|
||||||
|
bus = MagicMock()
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
|
||||||
|
# Mock the bot
|
||||||
|
sent_messages = []
|
||||||
|
|
||||||
|
class MockBot:
|
||||||
|
async def send_message(self, chat_id, text, parse_mode=None):
|
||||||
|
sent_messages.append({"chat_id": chat_id, "text": text})
|
||||||
|
|
||||||
|
class MockApp:
|
||||||
|
bot = MockBot()
|
||||||
|
|
||||||
|
channel._app = MockApp()
|
||||||
|
|
||||||
|
# Short message
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="123",
|
||||||
|
content="Short message."
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Should send exactly 1 message
|
||||||
|
assert len(sent_messages) == 1
|
||||||
|
assert sent_messages[0]["text"] == "Short message."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_long_message_splits_at_sentences():
|
||||||
|
"""Messages >4096 chars should split at sentence boundaries."""
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "test-token"
|
||||||
|
bus = MagicMock()
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
|
||||||
|
# Mock the bot
|
||||||
|
sent_messages = []
|
||||||
|
|
||||||
|
class MockBot:
|
||||||
|
async def send_message(self, chat_id, text, parse_mode=None):
|
||||||
|
sent_messages.append({"chat_id": chat_id, "text": text})
|
||||||
|
|
||||||
|
class MockApp:
|
||||||
|
bot = MockBot()
|
||||||
|
|
||||||
|
channel._app = MockApp()
|
||||||
|
|
||||||
|
# Create a message longer than 4096 chars with clear sentence boundaries
|
||||||
|
# Each sentence is 200 chars, need 21+ sentences to exceed 4096
|
||||||
|
sentence = "A" * 195 + "end. " # 200 chars including "end. "
|
||||||
|
long_content = sentence * 25 # 5000 chars total
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="123",
|
||||||
|
content=long_content
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Should split into multiple messages
|
||||||
|
assert len(sent_messages) > 1
|
||||||
|
|
||||||
|
# Each message should be under 4096 chars
|
||||||
|
for sent in sent_messages:
|
||||||
|
assert len(sent["text"]) <= 4096
|
||||||
|
|
||||||
|
# All messages combined should equal original (with whitespace trimming)
|
||||||
|
combined = "".join(sent["text"] for sent in sent_messages)
|
||||||
|
assert combined.replace(" ", "") == long_content.replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_message_at_exactly_4096_chars():
|
||||||
|
"""Message at exactly 4096 chars should not chunk."""
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "test-token"
|
||||||
|
bus = MagicMock()
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
|
||||||
|
# Mock the bot
|
||||||
|
sent_messages = []
|
||||||
|
|
||||||
|
class MockBot:
|
||||||
|
async def send_message(self, chat_id, text, parse_mode=None):
|
||||||
|
sent_messages.append({"chat_id": chat_id, "text": text})
|
||||||
|
|
||||||
|
class MockApp:
|
||||||
|
bot = MockBot()
|
||||||
|
|
||||||
|
channel._app = MockApp()
|
||||||
|
|
||||||
|
# Exactly 4096 chars
|
||||||
|
content = "A" * 4096
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="123",
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Should send exactly 1 message
|
||||||
|
assert len(sent_messages) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_message_preserves_sentence_boundaries():
|
||||||
|
"""Chunks should split at sentence endings, not mid-sentence."""
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "test-token"
|
||||||
|
bus = MagicMock()
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
|
||||||
|
# Mock the bot
|
||||||
|
sent_messages = []
|
||||||
|
|
||||||
|
class MockBot:
|
||||||
|
async def send_message(self, chat_id, text, parse_mode=None):
|
||||||
|
sent_messages.append({"chat_id": chat_id, "text": text})
|
||||||
|
|
||||||
|
class MockApp:
|
||||||
|
bot = MockBot()
|
||||||
|
|
||||||
|
channel._app = MockApp()
|
||||||
|
|
||||||
|
# Create content with clear sentence markers
|
||||||
|
# First part: just under 4096 chars
|
||||||
|
part1 = "First sentence. " * 250 # ~4000 chars
|
||||||
|
part2 = "Second sentence. "
|
||||||
|
content = part1 + part2
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="123",
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Verify chunks don't break mid-sentence
|
||||||
|
for sent in sent_messages:
|
||||||
|
text = sent["text"].strip()
|
||||||
|
# Each chunk should end with sentence punctuation
|
||||||
|
if text:
|
||||||
|
assert text[-1] in ".!?"
|
||||||
Reference in New Issue
Block a user