Source code for tools.discord_upload_file

"""Upload a file to a Discord channel, thread, or DM.

Handles all known Discord upload failure states including DM
resolution, file size limits (per guild boost tier), archived/locked
threads, forum/voice channels, rate limits, and transient network
errors.
"""

from __future__ import annotations

import asyncio
import io
import logging
import math
import os
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tool_context import ToolContext

logger = logging.getLogger(__name__)

TOOL_NAME = "discord_upload_file"
TOOL_DESCRIPTION = (
    "Uploads a file to a Discord channel, thread, or DM. Can create "
    "a file from text content or upload an existing file from disk. "
    "For DMs, you may pass a user_id instead of (or in addition to) "
    "the channel_id."
)
TOOL_PARAMETERS = {
    "type": "object",
    "properties": {
        "channel_id": {
            "type": "string",
            "description": (
                "The channel or thread ID to upload to. "
                "For DMs you can also pass the DM channel ID."
            ),
        },
        "user_id": {
            "type": "string",
            "description": (
                "Optional Discord user ID. When provided: if "
                "channel_id resolves to a valid channel, user_id "
                "is ignored; otherwise a DM is opened to this user "
                "and the file is uploaded there. You may omit "
                "channel_id entirely and provide only user_id to "
                "send a file via DM."
            ),
        },
        "content": {
            "type": "string",
            "description": (
                "String content to upload as a file. "
                "Requires 'filename' to be set."
            ),
        },
        "filename": {
            "type": "string",
            "description": (
                "Name for the file when uploading from 'content'."
            ),
        },
        "filepath": {
            "type": "string",
            "description": (
                "Local path to an existing file to upload."
            ),
        },
    },
    "required": [],
}

ALLOWED_UPLOAD_DIR = os.environ.get(
    "ALLOWED_UPLOAD_DIR", "/home/star/large_files",
)

# Discord upload limits by guild premium tier (bytes).
_UPLOAD_LIMITS = {
    0: 25 * 1024 * 1024,   # No boost  – 25 MB
    1: 25 * 1024 * 1024,   # Tier 1    – 25 MB
    2: 50 * 1024 * 1024,   # Tier 2    – 50 MB
    3: 100 * 1024 * 1024,  # Tier 3    – 100 MB
}
_DM_UPLOAD_LIMIT = 25 * 1024 * 1024  # DMs: 25 MB

# Maximum retries for transient failures.
_MAX_RETRIES = 1


# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------

def _get_upload_limit(channel) -> int:
    """Return the max upload size in bytes for *channel*."""
    guild = getattr(channel, "guild", None)
    if guild is None:
        return _DM_UPLOAD_LIMIT
    return _UPLOAD_LIMITS.get(guild.premium_tier, _DM_UPLOAD_LIMIT)


def _friendly_size(size_bytes: int) -> str:
    """Return a human-readable file-size string."""
    if size_bytes < 1024:
        return f"{size_bytes} B"
    elif size_bytes < 1024 * 1024:
        return f"{size_bytes / 1024:.1f} KB"
    else:
        return f"{size_bytes / (1024 * 1024):.1f} MB"


def _check_channel_sendable(channel) -> str | None:
    """Validate *channel* can receive file uploads.

    Returns ``None`` if OK, or an error message string.
    """
    import discord

    # Forum channels require posting inside a thread.
    if isinstance(channel, discord.ForumChannel):
        return (
            "Error: Cannot upload directly to a forum channel. "
            "Please specify a thread (post) within the forum."
        )

    # Voice / stage channels don't support text messages.
    if isinstance(channel, (discord.VoiceChannel, discord.StageChannel)):
        return (
            "Error: Cannot upload files to a voice or stage "
            "channel."
        )

    # Category channels are organizational containers.
    if isinstance(channel, discord.CategoryChannel):
        return (
            "Error: Cannot upload files to a category channel. "
            "Please specify a text channel within the category."
        )

    # Threads: check archived / locked state.
    if isinstance(channel, discord.Thread):
        if channel.locked:
            return (
                f"Error: Thread '{channel.name}' is locked. "
                f"Files cannot be uploaded to locked threads."
            )
        if channel.archived:
            # We'll attempt to unarchive and proceed; if that fails
            # the send() call will raise Forbidden and we handle it.
            pass

    # Final generic check.
    if not hasattr(channel, "send"):
        return (
            f"Error: Channel '{channel.id}' does not support "
            f"sending messages."
        )

    return None


async def _resolve_channel(client, channel_id, user_id):
    """Resolve a sendable channel from *channel_id* and/or *user_id*.

    Returns the channel object or a ``str`` error message.
    """
    from tools._discord_helpers import (
        get_channel,
        resolve_dm_channel,
    )

    # Try channel_id first.
    if channel_id:
        result = await get_channel(client, channel_id)
        if not isinstance(result, str):
            return result
        # channel_id failed — fall through to user_id if available.
        channel_error = result
    else:
        channel_error = None

    # Fallback: open a DM via user_id.
    if user_id:
        dm_result = await resolve_dm_channel(client, user_id)
        if not isinstance(dm_result, str):
            return dm_result
        # Both failed — return the DM error (more relevant).
        return dm_result

    # No user_id fallback available.
    if channel_error:
        return channel_error
    return (
        "Error: You must provide either 'channel_id' or "
        "'user_id'."
    )


async def _send_file_with_retry(channel, discord_file, retries=_MAX_RETRIES):
    """Send *discord_file* to *channel* with retry on transient failures.

    Returns ``None`` on success or a ``str`` error message.
    """
    import discord
    import aiohttp

    last_error = None
    for attempt in range(1 + retries):
        try:
            await channel.send(file=discord_file)
            return None  # success
        except discord.errors.HTTPException as exc:
            if exc.status == 429:
                # Rate-limited — wait and retry.
                retry_after = getattr(exc, "retry_after", 5.0) or 5.0
                logger.warning(
                    "Rate-limited uploading to %s, retrying in %.1fs",
                    getattr(channel, "name", channel.id),
                    retry_after,
                )
                if attempt < retries:
                    await asyncio.sleep(retry_after)
                    # discord.File objects can only be sent once;
                    # the fp is already consumed so we cannot retry
                    # without re-creating the file. Return the error.
                    return (
                        f"Error: Rate-limited by Discord. Please "
                        f"wait {retry_after:.0f}s and try again."
                    )
                return (
                    f"Error: Rate-limited by Discord (retry after "
                    f"{retry_after:.0f}s)."
                )
            elif exc.status == 413:
                limit = _get_upload_limit(channel)
                return (
                    f"Error: File exceeds Discord's upload limit "
                    f"({_friendly_size(limit)}) for this server."
                )
            elif exc.code == 50006:
                return (
                    "Error: Discord rejected the upload — the "
                    "message was considered empty. Ensure the file "
                    "has content."
                )
            elif exc.code == 50007:
                return (
                    "Error: Cannot send a DM to this user. "
                    "They may have DMs disabled or have blocked "
                    "the bot."
                )
            else:
                last_error = exc
                logger.warning(
                    "HTTPException %s/%s uploading file: %s",
                    exc.status, exc.code, exc,
                )
                if attempt < retries:
                    await asyncio.sleep(1.0)
                    continue
        except discord.errors.Forbidden:
            ch_name = getattr(channel, "name", channel.id)
            return (
                f"Error: I don't have permission to upload files "
                f"to channel '{ch_name}'."
            )
        except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
            logger.warning(
                "Transient network error uploading file: %s", exc,
            )
            last_error = exc
            if attempt < retries:
                await asyncio.sleep(2.0)
                continue

    if last_error:
        return f"Error: Upload failed after retries: {last_error}"
    return "Error: Upload failed for an unknown reason."


# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------

[docs] async def run( channel_id: str | None = None, user_id: str | None = None, content: str | None = None, filename: str | None = None, filepath: str | None = None, ctx: ToolContext | None = None, ) -> str: """Execute this tool and return the result. Args: channel_id (str | None): Discord/Matrix channel identifier. user_id (str | None): Unique identifier for the user. content (str | None): Content data. filename (str | None): The filename value. filepath (str | None): The filepath value. ctx (ToolContext | None): Tool execution context providing access to bot internals. Returns: str: Result string. """ import discord from tools._discord_helpers import require_discord_client client = require_discord_client(ctx) if isinstance(client, str): return client # ---------------------------------------------------------- # 1. Resolve channel # ---------------------------------------------------------- channel = await _resolve_channel(client, channel_id, user_id) if isinstance(channel, str): return channel # ---------------------------------------------------------- # 2. Validate channel type # ---------------------------------------------------------- send_check = _check_channel_sendable(channel) if send_check is not None: return send_check # ---------------------------------------------------------- # 3. Unarchive thread if needed # ---------------------------------------------------------- if isinstance(channel, discord.Thread) and channel.archived: try: await channel.edit(archived=False) logger.info( "Unarchived thread '%s' for file upload", channel.name, ) except discord.errors.Forbidden: return ( f"Error: Thread '{channel.name}' is archived and " f"I don't have permission to unarchive it." ) except Exception as exc: return ( f"Error: Failed to unarchive thread " f"'{channel.name}': {exc}" ) # ---------------------------------------------------------- # 4. Determine upload limit # ---------------------------------------------------------- max_bytes = _get_upload_limit(channel) ch_name = getattr(channel, "name", None) or ( f"DM ({getattr(channel, 'recipient', 'unknown')})" ) # ---------------------------------------------------------- # 5. Upload from content + filename # ---------------------------------------------------------- if content is not None and filename: try: data = content.encode("utf-8", errors="replace") except Exception as exc: return f"Error encoding content: {exc}" data_len = len(data) if data_len == 0: return "Error: Cannot upload an empty file." # Auto-split if content exceeds the limit. if data_len > max_bytes: return await _upload_split_content( channel, content, filename, max_bytes, ) f = discord.File(io.BytesIO(data), filename=filename) err = await _send_file_with_retry(channel, f) if err: return err return ( f"Successfully uploaded file '{filename}' to " f"channel '{ch_name}'." ) if content is not None and not filename: return ( "Error: 'filename' is required when uploading from " "'content'. Please provide a filename (e.g., " "'output.txt')." ) if filename and content is None and not filepath: return ( "Error: 'content' is required when 'filename' is " "provided without 'filepath'." ) # ---------------------------------------------------------- # 6. Upload from filepath # ---------------------------------------------------------- if filepath: resolved = os.path.realpath(os.path.expanduser(filepath)) allowed = os.path.realpath(ALLOWED_UPLOAD_DIR) if ( not resolved.startswith(allowed + os.sep) and resolved != allowed ): return ( f"Error: Access denied. Files can only be " f"uploaded from within {ALLOWED_UPLOAD_DIR}" ) if not await asyncio.to_thread(os.path.exists, resolved): return f"Error: File not found at path: {filepath}" if not await asyncio.to_thread(os.path.isfile, resolved): return f"Error: Path is not a file: {filepath}" if not await asyncio.to_thread(os.access, resolved, os.R_OK): return ( f"Error: Permission denied reading file: " f"{filepath}" ) file_size = await asyncio.to_thread(os.path.getsize, resolved) if file_size == 0: return f"Error: File is empty: {filepath}" if file_size > max_bytes: return ( f"Error: File '{os.path.basename(resolved)}' is " f"{_friendly_size(file_size)}, which exceeds the " f"upload limit of {_friendly_size(max_bytes)} for " f"this {'server' if hasattr(channel, 'guild') and channel.guild else 'DM'}." ) # Use a context-managed file to ensure cleanup on failure. f = discord.File(resolved) try: err = await _send_file_with_retry(channel, f) finally: f.close() if err: return err return ( f"Successfully uploaded file from '{filepath}' " f"to channel '{ch_name}'." ) # ---------------------------------------------------------- # 7. Nothing provided # ---------------------------------------------------------- return ( "Error: Provide either 'content' and 'filename', or a " "'filepath'." )
# ------------------------------------------------------------------ # Split-upload for oversized text content # ------------------------------------------------------------------ async def _upload_split_content( channel, content: str, filename: str, max_bytes: int, ) -> str: """Split *content* into parts that fit under *max_bytes* and upload each.""" import discord # Leave a 1 KB margin for metadata overhead. effective_limit = max_bytes - 1024 if effective_limit < 1024: effective_limit = max_bytes data = content.encode("utf-8", errors="replace") total = len(data) num_parts = math.ceil(total / effective_limit) ch_name = getattr(channel, "name", None) or ( f"DM ({getattr(channel, 'recipient', 'unknown')})" ) base, ext = os.path.splitext(filename) uploaded = 0 for i in range(num_parts): start = i * effective_limit end = min(start + effective_limit, total) chunk = data[start:end] part_name = f"{base}_part{i + 1}{ext}" if num_parts > 1 else filename f = discord.File( io.BytesIO(chunk), filename=part_name, ) err = await _send_file_with_retry(channel, f) if err: # Report partial success if some parts uploaded. if uploaded > 0: return ( f"{err}{uploaded}/{num_parts} parts were " f"uploaded before the error." ) return err uploaded += 1 return ( f"Successfully uploaded '{filename}' to channel " f"'{ch_name}' (split into {num_parts} parts due to " f"size limit)." )