Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -362,8 +362,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
mid = getattr(mention, "id", None)
|
mid = getattr(mention, "id", None)
|
||||||
if not mid:
|
if not mid:
|
||||||
continue
|
continue
|
||||||
# Bot mentions have an empty user_id with a valid open_id
|
# Bot mentions have no user_id (None or "") but a valid open_id
|
||||||
if getattr(mid, "user_id", None) == "" and (getattr(mid, "open_id", None) or "").startswith("ou_"):
|
if not getattr(mid, "user_id", None) and (getattr(mid, "open_id", None) or "").startswith("ou_"):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from nanobot.config.schema import TelegramConfig
|
|||||||
from nanobot.utils.helpers import split_message
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
||||||
|
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
|
||||||
|
|
||||||
|
|
||||||
def _strip_md(s: str) -> str:
|
def _strip_md(s: str) -> str:
|
||||||
@@ -453,6 +454,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_message_metadata(message, user) -> dict:
|
def _build_message_metadata(message, user) -> dict:
|
||||||
"""Build common Telegram inbound metadata payload."""
|
"""Build common Telegram inbound metadata payload."""
|
||||||
|
reply_to = getattr(message, "reply_to_message", None)
|
||||||
return {
|
return {
|
||||||
"message_id": message.message_id,
|
"message_id": message.message_id,
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
@@ -461,8 +463,73 @@ class TelegramChannel(BaseChannel):
|
|||||||
"is_group": message.chat.type != "private",
|
"is_group": message.chat.type != "private",
|
||||||
"message_thread_id": getattr(message, "message_thread_id", None),
|
"message_thread_id": getattr(message, "message_thread_id", None),
|
||||||
"is_forum": bool(getattr(message.chat, "is_forum", False)),
|
"is_forum": bool(getattr(message.chat, "is_forum", False)),
|
||||||
|
"reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_reply_context(message) -> str | None:
|
||||||
|
"""Extract text from the message being replied to, if any."""
|
||||||
|
reply = getattr(message, "reply_to_message", None)
|
||||||
|
if not reply:
|
||||||
|
return None
|
||||||
|
text = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
|
||||||
|
if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN:
|
||||||
|
text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..."
|
||||||
|
return f"[Reply to: {text}]" if text else None
|
||||||
|
|
||||||
|
async def _download_message_media(
|
||||||
|
self, msg, *, add_failure_content: bool = False
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
"""Download media from a message (current or reply). Returns (media_paths, content_parts)."""
|
||||||
|
media_file = None
|
||||||
|
media_type = None
|
||||||
|
if getattr(msg, "photo", None):
|
||||||
|
media_file = msg.photo[-1]
|
||||||
|
media_type = "image"
|
||||||
|
elif getattr(msg, "voice", None):
|
||||||
|
media_file = msg.voice
|
||||||
|
media_type = "voice"
|
||||||
|
elif getattr(msg, "audio", None):
|
||||||
|
media_file = msg.audio
|
||||||
|
media_type = "audio"
|
||||||
|
elif getattr(msg, "document", None):
|
||||||
|
media_file = msg.document
|
||||||
|
media_type = "file"
|
||||||
|
elif getattr(msg, "video", None):
|
||||||
|
media_file = msg.video
|
||||||
|
media_type = "video"
|
||||||
|
elif getattr(msg, "video_note", None):
|
||||||
|
media_file = msg.video_note
|
||||||
|
media_type = "video"
|
||||||
|
elif getattr(msg, "animation", None):
|
||||||
|
media_file = msg.animation
|
||||||
|
media_type = "animation"
|
||||||
|
if not media_file or not self._app:
|
||||||
|
return [], []
|
||||||
|
try:
|
||||||
|
file = await self._app.bot.get_file(media_file.file_id)
|
||||||
|
ext = self._get_extension(
|
||||||
|
media_type,
|
||||||
|
getattr(media_file, "mime_type", None),
|
||||||
|
getattr(media_file, "file_name", None),
|
||||||
|
)
|
||||||
|
media_dir = get_media_dir("telegram")
|
||||||
|
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
|
||||||
|
await file.download_to_drive(str(file_path))
|
||||||
|
path_str = str(file_path)
|
||||||
|
if media_type in ("voice", "audio"):
|
||||||
|
transcription = await self.transcribe_audio(file_path)
|
||||||
|
if transcription:
|
||||||
|
logger.info("Transcribed {}: {}...", media_type, transcription[:50])
|
||||||
|
return [path_str], [f"[transcription: {transcription}]"]
|
||||||
|
return [path_str], [f"[{media_type}: {path_str}]"]
|
||||||
|
return [path_str], [f"[{media_type}: {path_str}]"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to download message media: {}", e)
|
||||||
|
if add_failure_content:
|
||||||
|
return [], [f"[{media_type}: download failed]"]
|
||||||
|
return [], []
|
||||||
|
|
||||||
async def _ensure_bot_identity(self) -> tuple[int | None, str | None]:
|
async def _ensure_bot_identity(self) -> tuple[int | None, str | None]:
|
||||||
"""Load bot identity once and reuse it for mention/reply checks."""
|
"""Load bot identity once and reuse it for mention/reply checks."""
|
||||||
if self._bot_user_id is not None or self._bot_username is not None:
|
if self._bot_user_id is not None or self._bot_username is not None:
|
||||||
@@ -547,7 +614,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=self._sender_id(user),
|
sender_id=self._sender_id(user),
|
||||||
chat_id=str(message.chat_id),
|
chat_id=str(message.chat_id),
|
||||||
content=message.text,
|
content=message.text or "",
|
||||||
metadata=self._build_message_metadata(message, user),
|
metadata=self._build_message_metadata(message, user),
|
||||||
session_key=self._derive_topic_session_key(message),
|
session_key=self._derive_topic_session_key(message),
|
||||||
)
|
)
|
||||||
@@ -579,54 +646,26 @@ class TelegramChannel(BaseChannel):
|
|||||||
if message.caption:
|
if message.caption:
|
||||||
content_parts.append(message.caption)
|
content_parts.append(message.caption)
|
||||||
|
|
||||||
# Handle media files
|
# Download current message media
|
||||||
media_file = None
|
current_media_paths, current_media_parts = await self._download_message_media(
|
||||||
media_type = None
|
message, add_failure_content=True
|
||||||
|
|
||||||
if message.photo:
|
|
||||||
media_file = message.photo[-1] # Largest photo
|
|
||||||
media_type = "image"
|
|
||||||
elif message.voice:
|
|
||||||
media_file = message.voice
|
|
||||||
media_type = "voice"
|
|
||||||
elif message.audio:
|
|
||||||
media_file = message.audio
|
|
||||||
media_type = "audio"
|
|
||||||
elif message.document:
|
|
||||||
media_file = message.document
|
|
||||||
media_type = "file"
|
|
||||||
|
|
||||||
# Download media if present
|
|
||||||
if media_file and self._app:
|
|
||||||
try:
|
|
||||||
file = await self._app.bot.get_file(media_file.file_id)
|
|
||||||
ext = self._get_extension(
|
|
||||||
media_type,
|
|
||||||
getattr(media_file, 'mime_type', None),
|
|
||||||
getattr(media_file, 'file_name', None),
|
|
||||||
)
|
)
|
||||||
media_dir = get_media_dir("telegram")
|
media_paths.extend(current_media_paths)
|
||||||
|
content_parts.extend(current_media_parts)
|
||||||
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
|
if current_media_paths:
|
||||||
await file.download_to_drive(str(file_path))
|
logger.debug("Downloaded message media to {}", current_media_paths[0])
|
||||||
|
|
||||||
media_paths.append(str(file_path))
|
|
||||||
|
|
||||||
if media_type in ("voice", "audio"):
|
|
||||||
transcription = await self.transcribe_audio(file_path)
|
|
||||||
if transcription:
|
|
||||||
logger.info("Transcribed {}: {}...", media_type, transcription[:50])
|
|
||||||
content_parts.append(f"[transcription: {transcription}]")
|
|
||||||
else:
|
|
||||||
content_parts.append(f"[{media_type}: {file_path}]")
|
|
||||||
else:
|
|
||||||
content_parts.append(f"[{media_type}: {file_path}]")
|
|
||||||
|
|
||||||
logger.debug("Downloaded {} to {}", media_type, file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to download media: {}", e)
|
|
||||||
content_parts.append(f"[{media_type}: download failed]")
|
|
||||||
|
|
||||||
|
# Reply context: text and/or media from the replied-to message
|
||||||
|
reply = getattr(message, "reply_to_message", None)
|
||||||
|
if reply is not None:
|
||||||
|
reply_ctx = self._extract_reply_context(message)
|
||||||
|
reply_media, reply_media_parts = await self._download_message_media(reply)
|
||||||
|
if reply_media:
|
||||||
|
media_paths = reply_media + media_paths
|
||||||
|
logger.debug("Attached replied-to media: {}", reply_media[0])
|
||||||
|
tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None)
|
||||||
|
if tag:
|
||||||
|
content_parts.insert(0, tag)
|
||||||
content = "\n".join(content_parts) if content_parts else "[empty message]"
|
content = "\n".join(content_parts) if content_parts else "[empty message]"
|
||||||
|
|
||||||
logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
|
logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.telegram import TelegramChannel
|
from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel
|
||||||
from nanobot.config.schema import TelegramConfig
|
from nanobot.config.schema import TelegramConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +45,12 @@ class _FakeBot:
|
|||||||
async def send_chat_action(self, **kwargs) -> None:
|
async def send_chat_action(self, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def get_file(self, file_id: str):
|
||||||
|
"""Return a fake file that 'downloads' to a path (for reply-to-media tests)."""
|
||||||
|
async def _fake_download(path) -> None:
|
||||||
|
pass
|
||||||
|
return SimpleNamespace(download_to_drive=_fake_download)
|
||||||
|
|
||||||
|
|
||||||
class _FakeApp:
|
class _FakeApp:
|
||||||
def __init__(self, on_start_polling) -> None:
|
def __init__(self, on_start_polling) -> None:
|
||||||
@@ -336,3 +345,255 @@ async def test_group_policy_open_accepts_plain_group_message() -> None:
|
|||||||
|
|
||||||
assert len(handled) == 1
|
assert len(handled) == 1
|
||||||
assert channel._app.bot.get_me_calls == 0
|
assert channel._app.bot.get_me_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reply_context_no_reply() -> None:
|
||||||
|
"""When there is no reply_to_message, _extract_reply_context returns None."""
|
||||||
|
message = SimpleNamespace(reply_to_message=None)
|
||||||
|
assert TelegramChannel._extract_reply_context(message) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reply_context_with_text() -> None:
|
||||||
|
"""When reply has text, return prefixed string."""
|
||||||
|
reply = SimpleNamespace(text="Hello world", caption=None)
|
||||||
|
message = SimpleNamespace(reply_to_message=reply)
|
||||||
|
assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reply_context_with_caption_only() -> None:
|
||||||
|
"""When reply has only caption (no text), caption is used."""
|
||||||
|
reply = SimpleNamespace(text=None, caption="Photo caption")
|
||||||
|
message = SimpleNamespace(reply_to_message=reply)
|
||||||
|
assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reply_context_truncation() -> None:
|
||||||
|
"""Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN."""
|
||||||
|
long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100)
|
||||||
|
reply = SimpleNamespace(text=long_text, caption=None)
|
||||||
|
message = SimpleNamespace(reply_to_message=reply)
|
||||||
|
result = TelegramChannel._extract_reply_context(message)
|
||||||
|
assert result is not None
|
||||||
|
assert result.startswith("[Reply to: ")
|
||||||
|
assert result.endswith("...]")
|
||||||
|
assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reply_context_no_text_returns_none() -> None:
|
||||||
|
"""When reply has no text/caption, _extract_reply_context returns None (media handled separately)."""
|
||||||
|
reply = SimpleNamespace(text=None, caption=None)
|
||||||
|
message = SimpleNamespace(reply_to_message=reply)
|
||||||
|
assert TelegramChannel._extract_reply_context(message) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_includes_reply_context() -> None:
|
||||||
|
"""When user replies to a message, content passed to bus starts with reply context."""
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
handled = []
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
reply = SimpleNamespace(text="Hello", message_id=2, from_user=SimpleNamespace(id=1))
|
||||||
|
update = _make_telegram_update(text="translate this", reply_to_message=reply)
|
||||||
|
await channel._on_message(update, None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert handled[0]["content"].startswith("[Reply to: Hello]")
|
||||||
|
assert "translate this" in handled[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_message_media_returns_path_when_download_succeeds(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
|
"""_download_message_media returns (paths, content_parts) when bot.get_file and download succeed."""
|
||||||
|
media_dir = tmp_path / "media" / "telegram"
|
||||||
|
media_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.telegram.get_media_dir",
|
||||||
|
lambda channel=None: media_dir if channel else tmp_path / "media",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
channel._app.bot.get_file = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None))
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
photo=[SimpleNamespace(file_id="fid123", mime_type="image/jpeg")],
|
||||||
|
voice=None,
|
||||||
|
audio=None,
|
||||||
|
document=None,
|
||||||
|
video=None,
|
||||||
|
video_note=None,
|
||||||
|
animation=None,
|
||||||
|
)
|
||||||
|
paths, parts = await channel._download_message_media(msg)
|
||||||
|
assert len(paths) == 1
|
||||||
|
assert len(parts) == 1
|
||||||
|
assert "fid123" in paths[0]
|
||||||
|
assert "[image:" in parts[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_attaches_reply_to_media_when_available(monkeypatch, tmp_path) -> None:
|
||||||
|
"""When user replies to a message with media, that media is downloaded and attached to the turn."""
|
||||||
|
media_dir = tmp_path / "media" / "telegram"
|
||||||
|
media_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.telegram.get_media_dir",
|
||||||
|
lambda channel=None: media_dir if channel else tmp_path / "media",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
app = _FakeApp(lambda: None)
|
||||||
|
app.bot.get_file = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None))
|
||||||
|
)
|
||||||
|
channel._app = app
|
||||||
|
handled = []
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
reply_with_photo = SimpleNamespace(
|
||||||
|
text=None,
|
||||||
|
caption=None,
|
||||||
|
photo=[SimpleNamespace(file_id="reply_photo_fid", mime_type="image/jpeg")],
|
||||||
|
document=None,
|
||||||
|
voice=None,
|
||||||
|
audio=None,
|
||||||
|
video=None,
|
||||||
|
video_note=None,
|
||||||
|
animation=None,
|
||||||
|
)
|
||||||
|
update = _make_telegram_update(
|
||||||
|
text="what is the image?",
|
||||||
|
reply_to_message=reply_with_photo,
|
||||||
|
)
|
||||||
|
await channel._on_message(update, None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert handled[0]["content"].startswith("[Reply to: [image:")
|
||||||
|
assert "what is the image?" in handled[0]["content"]
|
||||||
|
assert len(handled[0]["media"]) == 1
|
||||||
|
assert "reply_photo_fid" in handled[0]["media"][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_reply_to_media_fallback_when_download_fails() -> None:
|
||||||
|
"""When reply has media but download fails, no media attached and no reply tag."""
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
channel._app.bot.get_file = None
|
||||||
|
handled = []
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
reply_with_photo = SimpleNamespace(
|
||||||
|
text=None,
|
||||||
|
caption=None,
|
||||||
|
photo=[SimpleNamespace(file_id="x", mime_type="image/jpeg")],
|
||||||
|
document=None,
|
||||||
|
voice=None,
|
||||||
|
audio=None,
|
||||||
|
video=None,
|
||||||
|
video_note=None,
|
||||||
|
animation=None,
|
||||||
|
)
|
||||||
|
update = _make_telegram_update(text="what is this?", reply_to_message=reply_with_photo)
|
||||||
|
await channel._on_message(update, None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert "what is this?" in handled[0]["content"]
|
||||||
|
assert handled[0]["media"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_reply_to_caption_and_media(monkeypatch, tmp_path) -> None:
|
||||||
|
"""When replying to a message with caption + photo, both text context and media are included."""
|
||||||
|
media_dir = tmp_path / "media" / "telegram"
|
||||||
|
media_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.telegram.get_media_dir",
|
||||||
|
lambda channel=None: media_dir if channel else tmp_path / "media",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
app = _FakeApp(lambda: None)
|
||||||
|
app.bot.get_file = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None))
|
||||||
|
)
|
||||||
|
channel._app = app
|
||||||
|
handled = []
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
reply_with_caption_and_photo = SimpleNamespace(
|
||||||
|
text=None,
|
||||||
|
caption="A cute cat",
|
||||||
|
photo=[SimpleNamespace(file_id="cat_fid", mime_type="image/jpeg")],
|
||||||
|
document=None,
|
||||||
|
voice=None,
|
||||||
|
audio=None,
|
||||||
|
video=None,
|
||||||
|
video_note=None,
|
||||||
|
animation=None,
|
||||||
|
)
|
||||||
|
update = _make_telegram_update(
|
||||||
|
text="what breed is this?",
|
||||||
|
reply_to_message=reply_with_caption_and_photo,
|
||||||
|
)
|
||||||
|
await channel._on_message(update, None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert "[Reply to: A cute cat]" in handled[0]["content"]
|
||||||
|
assert "what breed is this?" in handled[0]["content"]
|
||||||
|
assert len(handled[0]["media"]) == 1
|
||||||
|
assert "cat_fid" in handled[0]["media"][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forward_command_does_not_inject_reply_context() -> None:
|
||||||
|
"""Slash commands forwarded via _forward_command must not include reply context."""
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
handled = []
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
|
||||||
|
reply = SimpleNamespace(text="some old message", message_id=2, from_user=SimpleNamespace(id=1))
|
||||||
|
update = _make_telegram_update(text="/new", reply_to_message=reply)
|
||||||
|
await channel._forward_command(update, None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert handled[0]["content"] == "/new"
|
||||||
|
|||||||
Reference in New Issue
Block a user