Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1264,8 +1264,11 @@ def _log_safe_path(path: str) -> str:
SUPPORTED_DOCUMENT_TYPES = {
".pdf": "application/pdf",
".md": "text/markdown",
".html": "text/html",
".htm": "text/html",
".txt": "text/plain",
".csv": "text/csv",
".tsv": "text/tab-separated-values",
".log": "text/plain",
".json": "application/json",
".xml": "application/xml",
Expand Down Expand Up @@ -1520,9 +1523,9 @@ def cache_media_bytes(

``default_kind`` ("image"/"video"/"audio"/"document") biases classification
when the extension/MIME are ambiguous — e.g. a Telegram native photo whose
file has no usable name. Any non-image/video/audio file is cached as a
document and surfaced to the agent (arbitrary types get
``application/octet-stream``); only images that fail validation
file has no usable name. Non-media attachments are cached as documents even
when their extension is not in the common-type map; the map only supplies a
canonical MIME for known types. Images that fail validation
(``cache_image_from_bytes`` raises ValueError) return None.
"""
from tools.credential_files import to_agent_visible_cache_path
Expand Down Expand Up @@ -1563,9 +1566,8 @@ def cache_media_bytes(
# so it can be inspected with terminal / read_file / etc. Authorization to
# talk to the agent is the gate that matters — once a user is allowed to
# message it, the file-extension allowlist must not silently drop their
# uploads. Known extensions keep their precise MIME; everything else is
# tagged application/octet-stream (or the caller-supplied MIME) so the
# agent knows it's an arbitrary file and reaches for terminal tools.
# uploads. Known extensions keep their precise MIME; everything else keeps
# the caller-supplied MIME or falls back to application/octet-stream.
fallback_name = filename or (f"document{ext}" if ext else "document.bin")
path = cache_document_from_bytes(data, fallback_name)
if ext in SUPPORTED_DOCUMENT_TYPES:
Expand Down
6 changes: 4 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1887,8 +1887,10 @@ def _build_document_context_note(display_name: str, agent_path: str, mtype: str)
if mtype.startswith("text/"):
return (
f"[The user sent a text document: '{display_name}'. "
f"Its content has been included below. "
f"The file is also saved at: {agent_path}]"
f"Its content may be included below if it was small enough to inline. "
f"The original file is saved at: {agent_path}. "
f"If the content is not shown below, or if the request depends on the exact source, "
f"read the saved path with file tools before answering.]"
)
return (
f"[The user sent a document: '{display_name}'. It is saved at: {agent_path}. "
Expand Down
11 changes: 6 additions & 5 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6669,15 +6669,16 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
# ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead
# code — the extension sets are identical.

# Download and cache. Any file type is accepted — authorization
# to message the agent is the gate, not the file extension.
# Known types keep their precise MIME; unknown types are tagged
# application/octet-stream so the agent reaches for terminal tools.
# Download and cache every non-media Telegram document. Any file
# type is accepted — authorization to message the agent is the
# gate, not the file extension. Known extensions get canonical
# MIME types; unknown types keep the caller-supplied MIME or are
# tagged application/octet-stream so the agent reaches for tools.
file_obj = await doc.get_file()
doc_bytes = await file_obj.download_as_bytearray()
raw_bytes = bytes(doc_bytes)
cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext or '.bin'}")
mime_type = SUPPORTED_DOCUMENT_TYPES.get(ext) or doc.mime_type or "application/octet-stream"
mime_type = SUPPORTED_DOCUMENT_TYPES.get(ext) or doc_mime or "application/octet-stream"
event.media_urls = [cached_path]
event.media_types = [mime_type]
logger.info("[Telegram] Cached user document at %s (%s)", cached_path, mime_type)
Expand Down
11 changes: 11 additions & 0 deletions tests/gateway/test_document_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,11 @@ def test_all_extensions_have_mime_types(self):
[
".pdf",
".md",
".html",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior test added below is the regression contract for HTML routing. Adding individual extensions to this static list turns it into a catalog snapshot; please rely on the behavior test rather than expanding this enumeration.

".htm",
".txt",
".zip",
".tsv",
".doc",
".docx",
".xls",
Expand Down Expand Up @@ -238,6 +241,14 @@ def test_unknown_document_no_mime_falls_back_to_octet_stream(self):
assert result.kind == "document"
assert result.media_type == "application/octet-stream"

def test_html_routes_to_text_document(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"<!doctype html><html></html>", filename="plan.html", mime_type="")
assert result is not None
assert result.kind == "document"
assert result.media_type == "text/html"
assert "plan.html" in result.display_name

def test_invalid_image_returns_none(self):
from gateway.platforms.base import cache_media_bytes
result = cache_media_bytes(b"<html>not an image</html>", filename="x.png", mime_type="image/png")
Expand Down
12 changes: 7 additions & 5 deletions tests/gateway/test_document_context_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
they'd like you to do with it" — steering it away from extracting the text it
is perfectly capable of reading. These tests pin the contract:

- text documents: note confirms the (adapter-)inlined content + records path.
- text documents: note records path and tells the agent to read the saved file
if content was too large to inline.
- binary documents (PDF/DOCX/…): note tells the agent to extract the text
itself and never tells it to punt back to the user.
"""
Expand All @@ -20,12 +21,13 @@

class TestTextDocumentNote:
@pytest.mark.parametrize("mtype", ["text/plain", "text/markdown", "text/csv"])
def test_text_note_mentions_included_content_and_path(self, mtype):
def test_text_note_mentions_optional_inline_content_and_path(self, mtype):
note = _build_document_context_note("notes.txt", "/cache/doc_notes.txt", mtype)
assert "text document" in note
assert "notes.txt" in note
assert "/cache/doc_notes.txt" in note
assert "included below" in note
assert "may be included below" in note
assert "read the saved path" in note


class TestBinaryDocumentNote:
Expand All @@ -52,6 +54,6 @@ def test_binary_note_distinct_from_text_note(self):
text_note = _build_document_context_note("a.txt", "/c/a.txt", "text/plain")
pdf_note = _build_document_context_note("a.pdf", "/c/a.pdf", "application/pdf")
assert text_note != pdf_note
# The text path claims content is inlined; the binary path must not.
assert "included below" in text_note
# The text path allows optional inline content; the binary path must not.
assert "may be included below" in text_note
assert "included below" not in pdf_note
50 changes: 48 additions & 2 deletions tests/gateway/test_telegram_group_gating.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,48 @@ async def _run():
asyncio.run(_run())


def test_triggered_html_document_is_accepted_and_inlined_when_small(monkeypatch, tmp_path):
async def _run():
adapter = _make_adapter(require_mention=False)
adapter._max_doc_bytes = 10 * 1024 * 1024
adapter.handle_message = AsyncMock()
cached_path = tmp_path / "doc_abc_plan.html"
monkeypatch.setattr(
"plugins.platforms.telegram.adapter.cache_document_from_bytes",
lambda _data, _filename: str(cached_path),
)
html_bytes = b"<!doctype html><html><body>GDV360 plan</body></html>"
file_obj = SimpleNamespace(
file_path="documents/plan.html",
download_as_bytearray=AsyncMock(return_value=bytearray(html_bytes)),
)
document = SimpleNamespace(
file_name="plan.html",
mime_type="text/html",
file_size=len(html_bytes),
get_file=AsyncMock(return_value=file_obj),
)
update = SimpleNamespace(
update_id=3007,
message=_group_document_message(caption="analisa", document=document),
effective_message=None,
)

await adapter._handle_media_message(update, SimpleNamespace())

adapter.handle_message.assert_awaited_once()
await_args = adapter.handle_message.await_args
assert await_args is not None
event = await_args.args[0]
assert event.message_type == MessageType.DOCUMENT
assert event.media_urls == [str(cached_path)]
assert event.media_types == ["text/html"]
assert "[Content of plan.html]:" in event.text
assert "GDV360 plan" in event.text

asyncio.run(_run())


# ---------------------------------------------------------------------------
# Replied-to media caching
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1180,15 +1222,16 @@ async def _run():
asyncio.run(_run())


def test_unmentioned_unsupported_document_observed_and_cached(monkeypatch):
def test_unmentioned_unknown_document_observed_and_cached(monkeypatch, tmp_path):
async def _run():
adapter = _make_adapter(
require_mention=True, allowed_chats=["-100"],
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
cache_doc = Mock(return_value="/tmp/program.exe")
cached_path = tmp_path / "doc_abc_program.exe"
cache_doc = Mock(return_value=str(cached_path))
monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc)
file_obj = SimpleNamespace(
file_path="documents/program.exe",
Expand All @@ -1208,6 +1251,9 @@ async def _run():
# extension. The observed message records a path-pointing note.
cache_doc.assert_called_once()
_, message, _ = store.messages[0]
assert message["observed"] is True
assert "program.exe" in message["content"]
assert str(cached_path) in message["content"]
assert "unsupported" not in message["content"].lower()

asyncio.run(_run())