Rebase onto upstream (a4d95fd)
#12
@@ -206,25 +206,89 @@ class TelegramChannel(BaseChannel):
|
||||
if msg.media:
|
||||
await self._send_with_media(chat_id, html_content, msg.media)
|
||||
else:
|
||||
# Text-only message
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=html_content,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
# Text-only message - split if too long
|
||||
await self._send_text_chunks(chat_id, html_content, parse_mode="HTML")
|
||||
except ValueError:
|
||||
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
||||
except Exception as e:
|
||||
# Fallback to plain text if HTML parsing fails
|
||||
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
|
||||
try:
|
||||
await self._app.bot.send_message(
|
||||
chat_id=int(msg.chat_id),
|
||||
text=msg.content
|
||||
)
|
||||
await self._send_text_chunks(int(msg.chat_id), msg.content, parse_mode=None)
|
||||
except Exception as 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:
|
||||
"""
|
||||
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