feat(discord): add document caching and text-file injection (#2503)

- Download and cache .pdf, .docx, .xlsx, .pptx attachments locally
  instead of passing expiring CDN URLs to the agent
- Inject .txt and .md content (≤100 KB) into event.text so the agent
  sees file content without needing to fetch the URL
- Add 20 MB size guard and SUPPORTED_DOCUMENT_TYPES allowlist
- Fix: unsupported types (.zip etc.) no longer get MessageType.DOCUMENT
- Add 9 unit tests in test_discord_document_handling.py

Mirrors the Slack implementation from PR #784. Discord CDN URLs are
publicly accessible so no auth header is needed (unlike Slack).

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>
This commit is contained in:
Teknium 2026-03-22 07:38:14 -07:00 committed by GitHub
parent 09fd007c6e
commit afe2f0abe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 420 additions and 5 deletions

View file

@ -43,6 +43,8 @@ from pathlib import Path as _Path
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
import re
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
@ -50,6 +52,8 @@ from gateway.platforms.base import (
SendResult,
cache_image_from_url,
cache_audio_from_url,
cache_document_from_bytes,
SUPPORTED_DOCUMENT_TYPES,
)
@ -1950,7 +1954,12 @@ class DiscordAdapter(BasePlatformAdapter):
elif att.content_type.startswith("audio/"):
msg_type = MessageType.AUDIO
else:
msg_type = MessageType.DOCUMENT
doc_ext = ""
if att.filename:
_, doc_ext = os.path.splitext(att.filename)
doc_ext = doc_ext.lower()
if doc_ext in SUPPORTED_DOCUMENT_TYPES:
msg_type = MessageType.DOCUMENT
break
# When auto-threading kicked in, route responses to the new thread
@ -1987,6 +1996,7 @@ class DiscordAdapter(BasePlatformAdapter):
# vision tool can access them reliably (Discord CDN URLs can expire).
media_urls = []
media_types = []
pending_text_injection: Optional[str] = None
for att in message.attachments:
content_type = att.content_type or "unknown"
if content_type.startswith("image/"):
@ -2018,12 +2028,70 @@ class DiscordAdapter(BasePlatformAdapter):
media_urls.append(att.url)
media_types.append(content_type)
else:
# Other attachments: keep the original URL
media_urls.append(att.url)
media_types.append(content_type)
# Document attachments: download, cache, and optionally inject text
ext = ""
if att.filename:
_, ext = os.path.splitext(att.filename)
ext = ext.lower()
if not ext and content_type:
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:
logger.warning(
"[Discord] Unsupported document type '%s' (%s), skipping",
ext or "unknown", content_type,
)
else:
MAX_DOC_BYTES = 20 * 1024 * 1024
if att.size and att.size > MAX_DOC_BYTES:
logger.warning(
"[Discord] Document too large (%s bytes), skipping: %s",
att.size, att.filename,
)
else:
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(
att.url,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
raise Exception(f"HTTP {resp.status}")
raw_bytes = await resp.read()
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 .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:
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:
event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection
event = MessageEvent(
text=message.content,
text=event_text,
message_type=msg_type,
source=source,
raw_message=message,