Skip to content
Closed
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
77 changes: 39 additions & 38 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -2928,50 +2928,51 @@ async def _handle_message(self, message: DiscordMessage) -> None:
if att.filename:
_, ext = os.path.splitext(att.filename)
ext = ext.lower()
if not ext and content_type:
content_type_base = content_type.split(";")[0].strip().lower() if content_type != "unknown" else ""
if not ext and content_type_base:
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
ext = mime_to_ext.get(content_type, "")
if ext not in SUPPORTED_DOCUMENT_TYPES:
ext = mime_to_ext.get(content_type_base, "")

MAX_DOC_BYTES = 32 * 1024 * 1024
if att.size and att.size > MAX_DOC_BYTES:
logger.warning(
"[Discord] Unsupported document type '%s' (%s), skipping",
ext or "unknown", content_type,
"[Discord] Document too large (%s bytes), skipping: %s",
att.size, att.filename,
)
else:
MAX_DOC_BYTES = 32 * 1024 * 1024
if att.size and att.size > MAX_DOC_BYTES:
try:
raw_bytes = await self._cache_discord_document(att, ext)
cached_path = cache_document_from_bytes(
raw_bytes, att.filename or f"document{ext}"
)
doc_mime = content_type_base or SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream")
media_urls.append(cached_path)
media_types.append(doc_mime)
msg_type = MessageType.DOCUMENT
logger.info("[Discord] Cached user document: %s", cached_path)
# Inject text content for plain-text documents (capped at 100 KB)
MAX_TEXT_INJECT_BYTES = 100 * 1024
textish_exts = {".md", ".txt", ".log", ".json", ".csv", ".xml", ".html", ".htm", ".yaml", ".yml", ".ini", ".toml"}
textish_mimes = {"application/json", "application/xml", "application/yaml", "application/x-yaml"}
if len(raw_bytes) <= MAX_TEXT_INJECT_BYTES and (
ext in textish_exts or content_type_base.startswith("text/") or content_type_base in textish_mimes
):
try:
text_content = raw_bytes.decode("utf-8")
display_name = att.filename or f"document{ext}"
display_name = re.sub(r"[^\w. -]", "_", display_name)
injection = f"[Content of {display_name}]:\n{text_content}"
if pending_text_injection:
pending_text_injection = f"{pending_text_injection}\n\n{injection}"
else:
pending_text_injection = injection
except UnicodeDecodeError:
pass
except Exception as e:
logger.warning(
"[Discord] Document too large (%s bytes), skipping: %s",
att.size, att.filename,
"[Discord] Failed to cache document %s: %s",
att.filename, e, exc_info=True,
)
else:
try:
raw_bytes = await self._cache_discord_document(att, ext)
cached_path = cache_document_from_bytes(
raw_bytes, att.filename or f"document{ext}"
)
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
media_urls.append(cached_path)
media_types.append(doc_mime)
logger.info("[Discord] Cached user document: %s", cached_path)
# Inject text content for plain-text documents (capped at 100 KB)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if ext in (".md", ".txt", ".log") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
try:
text_content = raw_bytes.decode("utf-8")
display_name = att.filename or f"document{ext}"
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
injection = f"[Content of {display_name}]:\n{text_content}"
if pending_text_injection:
pending_text_injection = f"{pending_text_injection}\n\n{injection}"
else:
pending_text_injection = injection
except UnicodeDecodeError:
pass
except Exception as e:
logger.warning(
"[Discord] Failed to cache document %s: %s",
att.filename, e, exc_info=True,
)

event_text = message.content
if pending_text_injection:
Expand Down
19 changes: 11 additions & 8 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1128,13 +1128,12 @@ async def _handle_slack_message(self, event: dict) -> None:
_, ext = os.path.splitext(original_filename)
ext = ext.lower()

mimetype_base = (mimetype or "").split(";")[0].strip().lower()

# Fallback: reverse-lookup from MIME type
if not ext and mimetype:
if not ext and mimetype_base:
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
ext = mime_to_ext.get(mimetype, "")

if ext not in SUPPORTED_DOCUMENT_TYPES:
continue # Skip unsupported file types silently
ext = mime_to_ext.get(mimetype_base, "")

# Check file size (Slack limit: 20 MB for bots)
file_size = f.get("size", 0)
Expand All @@ -1148,19 +1147,23 @@ async def _handle_slack_message(self, event: dict) -> None:
cached_path = cache_document_from_bytes(
raw_bytes, original_filename or f"document{ext}"
)
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
doc_mime = mimetype_base or SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream")
media_urls.append(cached_path)
media_types.append(doc_mime)
msg_type = MessageType.DOCUMENT
logger.debug("[Slack] Cached user document: %s", cached_path)

# Inject text content for .txt/.md files (capped at 100 KB)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
textish_exts = {".md", ".txt", ".log", ".json", ".csv", ".xml", ".html", ".htm", ".yaml", ".yml", ".ini", ".toml"}
textish_mimes = {"application/json", "application/xml", "application/yaml", "application/x-yaml"}
if len(raw_bytes) <= MAX_TEXT_INJECT_BYTES and (
ext in textish_exts or mimetype_base.startswith("text/") or mimetype_base in textish_mimes
):
try:
text_content = raw_bytes.decode("utf-8")
display_name = original_filename or f"document{ext}"
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
display_name = re.sub(r"[^\w. -]", "_", display_name)
injection = f"[Content of {display_name}]:\n{text_content}"
if text:
text = f"{injection}\n\n{text}"
Expand Down
30 changes: 6 additions & 24 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -2522,41 +2522,23 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
ext = mime_to_ext.get(doc.mime_type, "")

# Check if supported
if ext not in SUPPORTED_DOCUMENT_TYPES:
supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))
event.text = (
f"Unsupported document type '{ext or 'unknown'}'. "
f"Supported types: {supported_list}"
)
logger.info("[Telegram] Unsupported document type: %s", ext or "unknown")
await self.handle_message(event)
return

# Check file size (Telegram Bot API limit: 20 MB)
MAX_DOC_BYTES = 20 * 1024 * 1024
if not doc.file_size or doc.file_size > MAX_DOC_BYTES:
event.text = (
"The document is too large or its size could not be verified. "
"Maximum: 20 MB."
)
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
await self.handle_message(event)
return

# Download and cache
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}")
mime_type = SUPPORTED_DOCUMENT_TYPES[ext]
mime_type = (doc.mime_type or "").split(";")[0].strip().lower() or SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream")
event.media_urls = [cached_path]
event.media_types = [mime_type]
logger.info("[Telegram] Cached user document at %s", cached_path)

# For text files, inject content into event.text (capped at 100 KB)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
textish_exts = {".md", ".txt", ".log", ".json", ".csv", ".xml", ".html", ".htm", ".yaml", ".yml", ".ini", ".toml"}
textish_mimes = {"application/json", "application/xml", "application/yaml", "application/x-yaml"}
if len(raw_bytes) <= MAX_TEXT_INJECT_BYTES and (
ext in textish_exts or (mime_type or "").startswith("text/") or mime_type in textish_mimes
):
try:
text_content = raw_bytes.decode("utf-8")
display_name = original_filename or f"document{ext}"
Expand Down
16 changes: 16 additions & 0 deletions tests/gateway/test_discord_document_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,22 @@ async def test_zip_document_cached(self, adapter):
assert event.media_types == ["application/zip"]
assert event.message_type == MessageType.DOCUMENT

@pytest.mark.asyncio
async def test_json_document_cached(self, adapter):
"""A .json file should be treated as a document instead of being skipped."""
msg = make_message([
make_attachment(filename="payload.json", content_type="application/json; charset=utf-8")
])

with _mock_aiohttp_download(b'{"ok": true}'):
await adapter._handle_message(msg)

event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.DOCUMENT
assert len(event.media_urls) == 1
assert event.media_types == ["application/json"]
assert '{"ok": true}' in event.text

@pytest.mark.asyncio
async def test_download_error_handled(self, adapter):
"""If the HTTP download raises, the handler should not crash."""
Expand Down
38 changes: 28 additions & 10 deletions tests/gateway/test_telegram_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,25 +248,37 @@ async def test_zip_document_cached(self, adapter):
assert event.media_types == ["application/zip"]

@pytest.mark.asyncio
async def test_oversized_file_rejected(self, adapter):
doc = _make_document(file_name="huge.pdf", file_size=25 * 1024 * 1024)
async def test_oversized_file_is_cached(self, adapter):
content = b"%PDF-1.4 big fake" + b"x" * 128
file_obj = _make_file_obj(content)
doc = _make_document(
file_name="huge.pdf",
file_size=25 * 1024 * 1024,
file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)

await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "too large" in event.text
assert len(event.media_urls) == 1
assert os.path.exists(event.media_urls[0])
assert event.media_types == ["application/pdf"]
assert "too large" not in (event.text or "")

@pytest.mark.asyncio
async def test_none_file_size_rejected(self, adapter):
"""Security fix: file_size=None must be rejected (not silently allowed)."""
doc = _make_document(file_name="tricky.pdf", file_size=None)
async def test_none_file_size_is_cached(self, adapter):
content = b"%PDF-1.4 unknown size" + b"x" * 64
file_obj = _make_file_obj(content)
doc = _make_document(file_name="tricky.pdf", file_size=None, file_obj=file_obj)
msg = _make_message(document=doc)
update = _make_update(msg)

await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "too large" in event.text or "could not be verified" in event.text
assert len(event.media_urls) == 1
assert os.path.exists(event.media_urls[0])
assert event.media_types == ["application/pdf"]

@pytest.mark.asyncio
async def test_missing_filename_uses_mime_lookup(self, adapter):
Expand All @@ -286,14 +298,20 @@ async def test_missing_filename_uses_mime_lookup(self, adapter):
assert event.media_types == ["application/pdf"]

@pytest.mark.asyncio
async def test_missing_filename_and_mime_rejected(self, adapter):
doc = _make_document(file_name=None, mime_type=None, file_size=100)
async def test_missing_filename_and_mime_cached(self, adapter):
"""When both filename and mime are missing, the file is still cached generically."""
content = b"opaque payload"
file_obj = _make_file_obj(content)
doc = _make_document(file_name=None, mime_type=None, file_size=len(content), file_obj=file_obj)
msg = _make_message(document=doc)
update = _make_update(msg)

await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "Unsupported" in event.text
assert len(event.media_urls) == 1
assert event.media_types == ["application/octet-stream"]
assert os.path.exists(event.media_urls[0])
assert "Unsupported" not in (event.text or "")

@pytest.mark.asyncio
async def test_unicode_decode_error_handled(self, adapter):
Expand Down