"""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)."
)