mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-24 05:41:40 +00:00
feat(discord): allow_any_attachment config to accept arbitrary file types
The Discord adapter silently dropped any attachment whose extension wasn't
in the SUPPORTED_DOCUMENT_TYPES allowlist (PDF, text family, zip, office).
Users uploading .wav / .bin / other unrecognized formats saw nothing in
their conversation — the file got logged as 'Unsupported document type'
and discarded before the agent ever saw it.
Add discord.allow_any_attachment (default false) to bypass the allowlist.
When on:
- Any file is downloaded, cached under ~/.hermes/cache/documents/, and
surfaced as a DOCUMENT-typed event with application/octet-stream MIME
- gateway/run.py already emits a context note with the cached path,
auto-translated via to_agent_visible_cache_path() for Docker/Modal
sandboxed terminals
- File body is NOT inlined — only the path — so binary uploads don't
blow up the context window
- Allowlisted text formats (.txt/.md/.log) keep their 100 KiB inline
behavior unchanged
Also adds discord.max_attachment_bytes (default 32 MiB matches the
historical hardcoded cap; 0 = unlimited) since users opting into arbitrary
types may want to raise the cap. The whole attachment is held in memory
while being cached, so unlimited carries a real memory cost.
Env overrides: DISCORD_ALLOW_ANY_ATTACHMENT, DISCORD_MAX_ATTACHMENT_BYTES.
Discord-only by deliberate scope. Telegram has hard 20 MB API limits and
Slack has its own caps — extending the same flag there is a separate
follow-up if/when requested.
This commit is contained in:
parent
3b39096904
commit
407a11b419
5 changed files with 258 additions and 10 deletions
|
|
@ -3564,6 +3564,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
return bool(configured)
|
||||
return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"}
|
||||
|
||||
def _discord_allow_any_attachment(self) -> bool:
|
||||
"""Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist.
|
||||
|
||||
When True, any uploaded file is cached to disk and surfaced to the
|
||||
agent as a local path so it can be inspected via terminal / read_file
|
||||
/ ffprobe / etc. Default False preserves the historical behaviour of
|
||||
dropping unsupported types with a warning log.
|
||||
"""
|
||||
configured = self.config.extra.get("allow_any_attachment")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() not in {"false", "0", "no", "off", ""}
|
||||
return bool(configured)
|
||||
return os.getenv("DISCORD_ALLOW_ANY_ATTACHMENT", "false").lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
def _discord_max_attachment_bytes(self) -> int:
|
||||
"""Return the per-attachment byte cap. 0 means unlimited.
|
||||
|
||||
The whole attachment is held in memory while being written to the
|
||||
cache, so unlimited carries a real memory cost. Default 32 MiB
|
||||
matches the historical hardcoded value.
|
||||
"""
|
||||
configured = self.config.extra.get("max_attachment_bytes")
|
||||
if configured is None:
|
||||
configured = os.getenv("DISCORD_MAX_ATTACHMENT_BYTES")
|
||||
if configured is None or configured == "":
|
||||
return 32 * 1024 * 1024
|
||||
try:
|
||||
value = int(configured)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[Discord] Invalid max_attachment_bytes value %r, falling back to 32 MiB",
|
||||
configured,
|
||||
)
|
||||
return 32 * 1024 * 1024
|
||||
return max(0, value)
|
||||
|
||||
def _discord_free_response_channels(self) -> set:
|
||||
"""Return Discord channel IDs where no bot mention is required.
|
||||
|
||||
|
|
@ -4495,6 +4532,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if normalized_content.startswith("/"):
|
||||
msg_type = MessageType.COMMAND
|
||||
elif all_attachments:
|
||||
_allow_any = self._discord_allow_any_attachment()
|
||||
# Check attachment types
|
||||
for att in all_attachments:
|
||||
if att.content_type:
|
||||
|
|
@ -4509,9 +4547,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if att.filename:
|
||||
_, doc_ext = os.path.splitext(att.filename)
|
||||
doc_ext = doc_ext.lower()
|
||||
if doc_ext in SUPPORTED_DOCUMENT_TYPES:
|
||||
if doc_ext in SUPPORTED_DOCUMENT_TYPES or _allow_any:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
elif _allow_any:
|
||||
# No content_type at all (rare — discord usually fills it
|
||||
# in). Treat as a document so downstream pipelines surface
|
||||
# the path to the agent.
|
||||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
|
||||
# When auto-threading kicked in, route responses to the new thread
|
||||
effective_channel = auto_threaded_channel or message.channel
|
||||
|
|
@ -4594,31 +4638,48 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
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:
|
||||
allow_any_attachment = self._discord_allow_any_attachment()
|
||||
in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES
|
||||
if not in_allowlist and not allow_any_attachment:
|
||||
logger.warning(
|
||||
"[Discord] Unsupported document type '%s' (%s), skipping",
|
||||
ext or "unknown", content_type,
|
||||
)
|
||||
else:
|
||||
MAX_DOC_BYTES = 32 * 1024 * 1024
|
||||
if att.size and att.size > MAX_DOC_BYTES:
|
||||
max_doc_bytes = self._discord_max_attachment_bytes()
|
||||
if max_doc_bytes and att.size and att.size > max_doc_bytes:
|
||||
logger.warning(
|
||||
"[Discord] Document too large (%s bytes), skipping: %s",
|
||||
att.size, att.filename,
|
||||
"[Discord] Document too large (%s bytes > cap %s), skipping: %s",
|
||||
att.size, max_doc_bytes, att.filename,
|
||||
)
|
||||
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}"
|
||||
raw_bytes, att.filename or f"document{ext or '.bin'}"
|
||||
)
|
||||
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
|
||||
if in_allowlist:
|
||||
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
|
||||
else:
|
||||
# allow_any_attachment path: untyped file. Use the
|
||||
# source content_type if discord gave us one,
|
||||
# otherwise fall back to octet-stream so the agent
|
||||
# knows it's binary and reaches for terminal tools.
|
||||
doc_mime = (
|
||||
content_type
|
||||
if content_type and content_type != "unknown"
|
||||
else "application/octet-stream"
|
||||
)
|
||||
media_urls.append(cached_path)
|
||||
media_types.append(doc_mime)
|
||||
logger.info("[Discord] Cached user document: %s", cached_path)
|
||||
logger.info(
|
||||
"[Discord] Cached user %s: %s",
|
||||
"document" if in_allowlist else "attachment",
|
||||
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:
|
||||
if in_allowlist and 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}"
|
||||
|
|
@ -4630,6 +4691,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
pending_text_injection = injection
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
# NOTE: for the allow_any_attachment path we deliberately
|
||||
# do NOT inject a path string here. ``gateway/run.py``
|
||||
# already detects DOCUMENT-typed events with
|
||||
# ``application/octet-stream`` MIME and emits a context
|
||||
# note with the sandbox-translated cache path via
|
||||
# ``to_agent_visible_cache_path()`` (important for
|
||||
# Docker/Modal terminal backends).
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[Discord] Failed to cache document %s: %s",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue