Rebase onto upstream (a4d95fd) #12

Closed
wylab wants to merge 144 commits from rebase-onto-upstream into main
2 changed files with 244 additions and 10 deletions
Showing only changes of commit 73d4e89fb7 - Show all commits
+74 -10
View File
@@ -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.
+170
View File
@@ -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 ".!?"