refactor: isolate Discord media upload methods before routing repair
This commit is contained in:
@@ -982,7 +982,10 @@ def _read_discord_prompt_timeout() -> int:
|
||||
return seconds
|
||||
|
||||
|
||||
class DiscordAdapter(BasePlatformAdapter):
|
||||
from plugins.platforms.discord.adapter_media import DiscordMediaMixin
|
||||
|
||||
|
||||
class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter):
|
||||
"""Discord bot adapter: guild/DM messages, threads, slash commands, button approvals, reactions."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
@@ -3139,149 +3142,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
success=True, message_id=last_id, continuation_message_ids=tuple(continuation_ids),
|
||||
)
|
||||
|
||||
async def _send_file_attachment(
|
||||
self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local file as a Discord attachment (forum channels get a new thread). Path-based
|
||||
``discord.File`` only: the open-handle form can race the multipart encoder after an image
|
||||
batch and yield zero attachments — a silent drop for video/document MEDIA tags.
|
||||
|
||||
See #66797.
|
||||
"""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
if not os.path.isfile(file_path):
|
||||
return SendResult(success=False, error=f"File not found: {file_path}")
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
filename = file_name or os.path.basename(file_path)
|
||||
logger.info(
|
||||
"[%s] Sending file attachment %s (%s) to %s", self.name, filename,
|
||||
os.path.splitext(filename)[1].lower() or "no-ext", chat_id,
|
||||
)
|
||||
# Path-based File (discord.py owns open/close); ``files=[...]`` over deprecated ``file=``.
|
||||
discord_file = discord.File(file_path, filename=filename)
|
||||
if self._is_forum_parent(channel):
|
||||
result = await self._forum_post_file(
|
||||
channel, content=(caption or "").strip(), files=[discord_file],
|
||||
)
|
||||
return result
|
||||
msg = await channel.send(content=caption if caption else None, files=[discord_file])
|
||||
attachments = getattr(msg, "attachments", None) or []
|
||||
if not attachments:
|
||||
# Discord accepted the message but attached nothing: fail loud instead of a silent drop.
|
||||
# Discord accepted the message but attached nothing — the failure mode reported in #66797 (MEDIA
|
||||
# video stripped from text, no attachment, no prior log line).
|
||||
logger.warning(
|
||||
"[%s] Discord returned message %s with no attachments for %s", self.name,
|
||||
getattr(msg, "id", "?"), filename,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=f"Discord accepted the message but attached no files ({filename})",
|
||||
message_id=str(getattr(msg, "id", "") or "") or None,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
async def send_multiple_images(
|
||||
self, chat_id: str, images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send images as one Discord message (<=10 attachments): URLs are downloaded and uploaded
|
||||
inline (bare links don't render); on chunk failure the remainder uses the per-image loop."""
|
||||
if not self._client:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
try:
|
||||
import discord as _discord_mod
|
||||
import io as _io
|
||||
from urllib.parse import unquote as _unquote
|
||||
except Exception: # pragma: no cover
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
try:
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
CHUNK = 10
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
files: List[Any] = []
|
||||
captions: List[str] = []
|
||||
aiohttp_session = None
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
captions.append(alt_text)
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning("[%s] Skipping missing image: %s", self.name, local_path)
|
||||
continue
|
||||
files.append(_discord_mod.File(local_path, filename=os.path.basename(local_path)))
|
||||
else:
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("[%s] Blocked unsafe image URL in batch", self.name)
|
||||
continue
|
||||
# Download to BytesIO so it renders inline
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
if aiohttp_session is None:
|
||||
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
|
||||
status, data, headers = await _read_url_image_with_redirect_guard(
|
||||
aiohttp_session, image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30), request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
logger.warning(
|
||||
"[%s] Failed to download image (HTTP %d) in batch: %s",
|
||||
self.name, status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
ext = _image_ext_from_content_type(headers.get("content-type", "image/png"))
|
||||
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
|
||||
except Exception as dl_err:
|
||||
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
|
||||
continue
|
||||
if not files:
|
||||
continue
|
||||
# Use the first caption if any (Discord only has one message body for the group)
|
||||
content = captions[0] if captions else None
|
||||
logger.info(
|
||||
"[%s] Sending %d image(s) as single Discord message (chunk %d/%d)",
|
||||
self.name, len(files), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
if self._is_forum_parent(channel):
|
||||
await self._forum_post_file(
|
||||
channel, content=(content or "").strip(), files=files,
|
||||
)
|
||||
else:
|
||||
await channel.send(content=content, files=files)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s",
|
||||
self.name, chunk_idx + 1, len(chunks), e, exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
finally:
|
||||
if aiohttp_session is not None:
|
||||
try:
|
||||
await aiohttp_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def play_tts(self, chat_id: str, audio_path: str, **kwargs) -> SendResult:
|
||||
"""Play auto-TTS audio: in the guild's VC if joined, else as a file attachment."""
|
||||
@@ -3292,71 +3153,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=success)
|
||||
return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)
|
||||
|
||||
async def send_voice(
|
||||
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
|
||||
) -> SendResult:
|
||||
"""Send audio as a Discord file attachment."""
|
||||
try:
|
||||
import io
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
if not os.path.exists(audio_path):
|
||||
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
|
||||
filename = os.path.basename(audio_path)
|
||||
reference = self._reply_reference_for_send(reply_to, channel)
|
||||
with open(audio_path, "rb") as f:
|
||||
file_data = f.read()
|
||||
# Forum channels reject POST /messages (native voice path too); create a thread post instead.
|
||||
if self._is_forum_parent(channel):
|
||||
forum_file = discord.File(io.BytesIO(file_data), filename=filename)
|
||||
return await self._forum_post_file(
|
||||
channel, content=(caption or "").strip(), file=forum_file,
|
||||
)
|
||||
# Try sending as a native voice message via raw API (flags=8192).
|
||||
try:
|
||||
import base64
|
||||
try:
|
||||
from mutagen.oggopus import OggOpus
|
||||
duration_secs = OggOpus(audio_path).info.length
|
||||
except Exception:
|
||||
duration_secs = max(1.0, len(file_data) / 2000.0)
|
||||
payload_data = {
|
||||
"flags": 8192,
|
||||
"attachments": [{
|
||||
"id": "0", "filename": "voice-message.ogg", "duration_secs": round(duration_secs, 2),
|
||||
"waveform": base64.b64encode(bytes([128] * 256)).decode(),
|
||||
}],
|
||||
}
|
||||
if reference is not None:
|
||||
payload_data["message_reference"] = {"message_id": str(reply_to), "fail_if_not_exists": False}
|
||||
form = [
|
||||
{"name": "payload_json", "value": json.dumps(payload_data)},
|
||||
{
|
||||
"name": "files[0]", "value": file_data, "filename": "voice-message.ogg",
|
||||
"content_type": "audio/ogg",
|
||||
},
|
||||
]
|
||||
msg_data = await self._client.http.request(
|
||||
discord.http.Route("POST", "/channels/{channel_id}/messages", channel_id=channel.id),
|
||||
form=form,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg_data["id"]))
|
||||
except Exception as voice_err:
|
||||
logger.debug("Voice message flag failed, falling back to file: %s", voice_err)
|
||||
file = discord.File(io.BytesIO(file_data), filename=filename)
|
||||
try:
|
||||
msg = await channel.send(file=file, reference=reference)
|
||||
except Exception as send_err:
|
||||
if reference is not None and self._is_reply_reference_rejected(send_err):
|
||||
msg = await channel.send(file=file, reference=None)
|
||||
else:
|
||||
raise
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True)
|
||||
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
|
||||
|
||||
# --- Voice channel methods (join / leave / play) ---
|
||||
|
||||
@@ -4155,106 +3951,12 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.debug("[Discord] Admin notify via %s failed: %s", target, e)
|
||||
|
||||
async def _send_local_file(self, chat_id, path, caption, *, file_name=None, not_found: str, kind: str, fallback):
|
||||
"""Native attachment upload for a local file; missing file -> error, other failure -> base adapter."""
|
||||
try:
|
||||
return await self._send_file_attachment(chat_id, path, caption, file_name=file_name)
|
||||
except FileNotFoundError:
|
||||
return SendResult(success=False, error=f"{not_found}: {path}")
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to send %s, falling back to base adapter: %s", self.name, kind, e, exc_info=True)
|
||||
return await fallback()
|
||||
|
||||
async def send_image_file(
|
||||
self, chat_id: str, image_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively as a Discord file attachment."""
|
||||
return await self._send_local_file(
|
||||
chat_id, image_path, caption, not_found="Image file not found", kind="local image",
|
||||
fallback=lambda: super(DiscordAdapter, self).send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata),
|
||||
)
|
||||
|
||||
async def _send_url_media(
|
||||
self, chat_id: str, url: str, caption: Optional[str], *, kind: str,
|
||||
filename_for, fallback, metadata: Optional[dict], error_metadata: Optional[dict],
|
||||
) -> SendResult:
|
||||
"""Download ``url`` and post it as a native attachment (Discord renders those inline).
|
||||
``fallback(metadata)`` is the base-adapter URL send (``error_metadata`` after download failure)."""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
if not is_safe_url(url):
|
||||
logger.warning("[%s] Blocked unsafe %s URL during Discord send_%s", self.name, kind, kind)
|
||||
return await fallback(metadata)
|
||||
try:
|
||||
import aiohttp
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(resolve_proxy_url(platform_env_var="DISCORD_PROXY"))
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
status, data, headers = await _read_url_image_with_redirect_guard(
|
||||
session, url, timeout=aiohttp.ClientTimeout(total=30), request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
raise Exception(f"Failed to download {kind}: HTTP {status}")
|
||||
import io
|
||||
file = discord.File(io.BytesIO(data), filename=filename_for(headers))
|
||||
if self._is_forum_parent(channel):
|
||||
return await self._forum_post_file(channel, content=(caption or "").strip(), file=file)
|
||||
msg = await channel.send(content=caption if caption else None, file=file)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except ImportError:
|
||||
logger.warning("[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", self.name, exc_info=True)
|
||||
return await fallback(error_metadata)
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to send %s attachment, falling back to URL: %s", self.name, kind, e, exc_info=True)
|
||||
return await fallback(error_metadata)
|
||||
|
||||
async def send_image(
|
||||
self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image natively as a Discord file attachment."""
|
||||
return await self._send_url_media(
|
||||
chat_id, image_url, caption, kind="image",
|
||||
filename_for=lambda h: f"image.{_image_ext_from_content_type(h.get('content-type', 'image/png'))}",
|
||||
fallback=lambda md: super(DiscordAdapter, self).send_image(chat_id, image_url, caption, reply_to, metadata=md),
|
||||
metadata=metadata, error_metadata=None,
|
||||
)
|
||||
|
||||
async def send_animation(
|
||||
self, chat_id: str, animation_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an animated GIF natively as a Discord file attachment."""
|
||||
return await self._send_url_media(
|
||||
chat_id, animation_url, caption, kind="animation", filename_for=lambda _h: "animation.gif",
|
||||
fallback=lambda md: super(DiscordAdapter, self).send_animation(chat_id, animation_url, caption, reply_to, metadata=md),
|
||||
metadata=metadata, error_metadata=metadata,
|
||||
)
|
||||
|
||||
async def send_video(
|
||||
self, chat_id: str, video_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local video file natively as a Discord attachment."""
|
||||
return await self._send_local_file(
|
||||
chat_id, video_path, caption, not_found="Video file not found", kind="local video",
|
||||
fallback=lambda: super(DiscordAdapter, self).send_video(chat_id, video_path, caption, reply_to, metadata=metadata),
|
||||
)
|
||||
|
||||
async def send_document(
|
||||
self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an arbitrary file natively as a Discord attachment."""
|
||||
return await self._send_local_file(
|
||||
chat_id, file_path, caption, file_name=file_name, not_found="File not found", kind="document",
|
||||
fallback=lambda: super(DiscordAdapter, self).send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata),
|
||||
)
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""Start a persistent typing loop (POST typing every 12s; indicator lasts ~10s).
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Discord native media uploads and delivery fallbacks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from gateway.platforms.base import SendResult
|
||||
|
||||
logger = logging.getLogger("plugins.platforms.discord.adapter")
|
||||
|
||||
|
||||
class DiscordMediaMixin:
|
||||
async def _send_file_attachment(
|
||||
self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local file as a Discord attachment (forum channels get a new thread). Path-based
|
||||
``discord.File`` only: the open-handle form can race the multipart encoder after an image
|
||||
batch and yield zero attachments — a silent drop for video/document MEDIA tags.
|
||||
|
||||
See #66797.
|
||||
"""
|
||||
from plugins.platforms.discord.adapter import discord
|
||||
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
if not os.path.isfile(file_path):
|
||||
return SendResult(success=False, error=f"File not found: {file_path}")
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
filename = file_name or os.path.basename(file_path)
|
||||
logger.info(
|
||||
"[%s] Sending file attachment %s (%s) to %s", self.name, filename,
|
||||
os.path.splitext(filename)[1].lower() or "no-ext", chat_id,
|
||||
)
|
||||
# Path-based File (discord.py owns open/close); ``files=[...]`` over deprecated ``file=``.
|
||||
discord_file = discord.File(file_path, filename=filename)
|
||||
if self._is_forum_parent(channel):
|
||||
result = await self._forum_post_file(
|
||||
channel, content=(caption or "").strip(), files=[discord_file],
|
||||
)
|
||||
return result
|
||||
msg = await channel.send(content=caption if caption else None, files=[discord_file])
|
||||
attachments = getattr(msg, "attachments", None) or []
|
||||
if not attachments:
|
||||
# Discord accepted the message but attached nothing: fail loud instead of a silent drop.
|
||||
# Discord accepted the message but attached nothing — the failure mode reported in #66797 (MEDIA
|
||||
# video stripped from text, no attachment, no prior log line).
|
||||
logger.warning(
|
||||
"[%s] Discord returned message %s with no attachments for %s", self.name,
|
||||
getattr(msg, "id", "?"), filename,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=f"Discord accepted the message but attached no files ({filename})",
|
||||
message_id=str(getattr(msg, "id", "") or "") or None,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
|
||||
async def send_multiple_images(
|
||||
self, chat_id: str, images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send images as one Discord message (<=10 attachments): URLs are downloaded and uploaded
|
||||
inline (bare links don't render); on chunk failure the remainder uses the per-image loop."""
|
||||
from plugins.platforms.discord.adapter import _image_ext_from_content_type, _read_url_image_with_redirect_guard, is_safe_url
|
||||
|
||||
if not self._client:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
try:
|
||||
import discord as _discord_mod
|
||||
import io as _io
|
||||
from urllib.parse import unquote as _unquote
|
||||
except Exception: # pragma: no cover
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
try:
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
CHUNK = 10
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
files: List[Any] = []
|
||||
captions: List[str] = []
|
||||
aiohttp_session = None
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
captions.append(alt_text)
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning("[%s] Skipping missing image: %s", self.name, local_path)
|
||||
continue
|
||||
files.append(_discord_mod.File(local_path, filename=os.path.basename(local_path)))
|
||||
else:
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("[%s] Blocked unsafe image URL in batch", self.name)
|
||||
continue
|
||||
# Download to BytesIO so it renders inline
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
if aiohttp_session is None:
|
||||
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
|
||||
status, data, headers = await _read_url_image_with_redirect_guard(
|
||||
aiohttp_session, image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30), request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
logger.warning(
|
||||
"[%s] Failed to download image (HTTP %d) in batch: %s",
|
||||
self.name, status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
ext = _image_ext_from_content_type(headers.get("content-type", "image/png"))
|
||||
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
|
||||
except Exception as dl_err:
|
||||
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
|
||||
continue
|
||||
if not files:
|
||||
continue
|
||||
# Use the first caption if any (Discord only has one message body for the group)
|
||||
content = captions[0] if captions else None
|
||||
logger.info(
|
||||
"[%s] Sending %d image(s) as single Discord message (chunk %d/%d)",
|
||||
self.name, len(files), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
if self._is_forum_parent(channel):
|
||||
await self._forum_post_file(
|
||||
channel, content=(content or "").strip(), files=files,
|
||||
)
|
||||
else:
|
||||
await channel.send(content=content, files=files)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s",
|
||||
self.name, chunk_idx + 1, len(chunks), e, exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
finally:
|
||||
if aiohttp_session is not None:
|
||||
try:
|
||||
await aiohttp_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def send_voice(
|
||||
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
|
||||
) -> SendResult:
|
||||
"""Send audio as a Discord file attachment."""
|
||||
from plugins.platforms.discord.adapter import discord
|
||||
|
||||
try:
|
||||
import io
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
if not os.path.exists(audio_path):
|
||||
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
|
||||
filename = os.path.basename(audio_path)
|
||||
reference = self._reply_reference_for_send(reply_to, channel)
|
||||
with open(audio_path, "rb") as f:
|
||||
file_data = f.read()
|
||||
# Forum channels reject POST /messages (native voice path too); create a thread post instead.
|
||||
if self._is_forum_parent(channel):
|
||||
forum_file = discord.File(io.BytesIO(file_data), filename=filename)
|
||||
return await self._forum_post_file(
|
||||
channel, content=(caption or "").strip(), file=forum_file,
|
||||
)
|
||||
# Try sending as a native voice message via raw API (flags=8192).
|
||||
try:
|
||||
import base64
|
||||
try:
|
||||
from mutagen.oggopus import OggOpus
|
||||
duration_secs = OggOpus(audio_path).info.length
|
||||
except Exception:
|
||||
duration_secs = max(1.0, len(file_data) / 2000.0)
|
||||
payload_data = {
|
||||
"flags": 8192,
|
||||
"attachments": [{
|
||||
"id": "0", "filename": "voice-message.ogg", "duration_secs": round(duration_secs, 2),
|
||||
"waveform": base64.b64encode(bytes([128] * 256)).decode(),
|
||||
}],
|
||||
}
|
||||
if reference is not None:
|
||||
payload_data["message_reference"] = {"message_id": str(reply_to), "fail_if_not_exists": False}
|
||||
form = [
|
||||
{"name": "payload_json", "value": json.dumps(payload_data)},
|
||||
{
|
||||
"name": "files[0]", "value": file_data, "filename": "voice-message.ogg",
|
||||
"content_type": "audio/ogg",
|
||||
},
|
||||
]
|
||||
msg_data = await self._client.http.request(
|
||||
discord.http.Route("POST", "/channels/{channel_id}/messages", channel_id=channel.id),
|
||||
form=form,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg_data["id"]))
|
||||
except Exception as voice_err:
|
||||
logger.debug("Voice message flag failed, falling back to file: %s", voice_err)
|
||||
file = discord.File(io.BytesIO(file_data), filename=filename)
|
||||
try:
|
||||
msg = await channel.send(file=file, reference=reference)
|
||||
except Exception as send_err:
|
||||
if reference is not None and self._is_reply_reference_rejected(send_err):
|
||||
msg = await channel.send(file=file, reference=None)
|
||||
else:
|
||||
raise
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True)
|
||||
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
|
||||
|
||||
|
||||
async def _send_local_file(self, chat_id, path, caption, *, file_name=None, not_found: str, kind: str, fallback):
|
||||
"""Native attachment upload for a local file; missing file -> error, other failure -> base adapter."""
|
||||
try:
|
||||
return await self._send_file_attachment(chat_id, path, caption, file_name=file_name)
|
||||
except FileNotFoundError:
|
||||
return SendResult(success=False, error=f"{not_found}: {path}")
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to send %s, falling back to base adapter: %s", self.name, kind, e, exc_info=True)
|
||||
return await fallback()
|
||||
|
||||
|
||||
async def send_image_file(
|
||||
self, chat_id: str, image_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively as a Discord file attachment."""
|
||||
return await self._send_local_file(
|
||||
chat_id, image_path, caption, not_found="Image file not found", kind="local image",
|
||||
fallback=lambda: super(DiscordMediaMixin, self).send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata),
|
||||
)
|
||||
|
||||
|
||||
async def _send_url_media(
|
||||
self, chat_id: str, url: str, caption: Optional[str], *, kind: str,
|
||||
filename_for, fallback, metadata: Optional[dict], error_metadata: Optional[dict],
|
||||
) -> SendResult:
|
||||
"""Download ``url`` and post it as a native attachment (Discord renders those inline).
|
||||
``fallback(metadata)`` is the base-adapter URL send (``error_metadata`` after download failure)."""
|
||||
from plugins.platforms.discord.adapter import _read_url_image_with_redirect_guard, discord, is_safe_url
|
||||
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
if not is_safe_url(url):
|
||||
logger.warning("[%s] Blocked unsafe %s URL during Discord send_%s", self.name, kind, kind)
|
||||
return await fallback(metadata)
|
||||
try:
|
||||
import aiohttp
|
||||
channel = await self._resolve_channel(chat_id)
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(resolve_proxy_url(platform_env_var="DISCORD_PROXY"))
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
status, data, headers = await _read_url_image_with_redirect_guard(
|
||||
session, url, timeout=aiohttp.ClientTimeout(total=30), request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
raise Exception(f"Failed to download {kind}: HTTP {status}")
|
||||
import io
|
||||
file = discord.File(io.BytesIO(data), filename=filename_for(headers))
|
||||
if self._is_forum_parent(channel):
|
||||
return await self._forum_post_file(channel, content=(caption or "").strip(), file=file)
|
||||
msg = await channel.send(content=caption if caption else None, file=file)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except ImportError:
|
||||
logger.warning("[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", self.name, exc_info=True)
|
||||
return await fallback(error_metadata)
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to send %s attachment, falling back to URL: %s", self.name, kind, e, exc_info=True)
|
||||
return await fallback(error_metadata)
|
||||
|
||||
|
||||
async def send_image(
|
||||
self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image natively as a Discord file attachment."""
|
||||
from plugins.platforms.discord.adapter import _image_ext_from_content_type
|
||||
|
||||
return await self._send_url_media(
|
||||
chat_id, image_url, caption, kind="image",
|
||||
filename_for=lambda h: f"image.{_image_ext_from_content_type(h.get('content-type', 'image/png'))}",
|
||||
fallback=lambda md: super(DiscordMediaMixin, self).send_image(chat_id, image_url, caption, reply_to, metadata=md),
|
||||
metadata=metadata, error_metadata=None,
|
||||
)
|
||||
|
||||
|
||||
async def send_animation(
|
||||
self, chat_id: str, animation_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an animated GIF natively as a Discord file attachment."""
|
||||
return await self._send_url_media(
|
||||
chat_id, animation_url, caption, kind="animation", filename_for=lambda _h: "animation.gif",
|
||||
fallback=lambda md: super(DiscordMediaMixin, self).send_animation(chat_id, animation_url, caption, reply_to, metadata=md),
|
||||
metadata=metadata, error_metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
async def send_video(
|
||||
self, chat_id: str, video_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local video file natively as a Discord attachment."""
|
||||
return await self._send_local_file(
|
||||
chat_id, video_path, caption, not_found="Video file not found", kind="local video",
|
||||
fallback=lambda: super(DiscordMediaMixin, self).send_video(chat_id, video_path, caption, reply_to, metadata=metadata),
|
||||
)
|
||||
|
||||
|
||||
async def send_document(
|
||||
self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an arbitrary file natively as a Discord attachment."""
|
||||
return await self._send_local_file(
|
||||
chat_id, file_path, caption, file_name=file_name, not_found="File not found", kind="document",
|
||||
fallback=lambda: super(DiscordMediaMixin, self).send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user