EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Sending Automated Job Files to Telegram and the Bot API's 50 MB Limit
Linux

Sending Automated Job Files to Telegram and the Bot API's 50 MB Limit

by Ton-Or ·24 Sep 2026·8 min read
Sending Automated Job Files to Telegram and the Bot API's 50 MB Limit

An automated job that finishes a file and leaves it in a folder on my home machine is as good as never having run if I am not sitting at that machine when I want to see it. So I made the last step as short as possible by sending the file to my own chat as soon as it is ready.

Several automated jobs on my home machine produce files, including PDF summary reports, screenshots for checking work, and data files I need to review later. The problem is that everything piles up in folders on that machine. When I am away from home and want to look at something, I have to connect remotely to retrieve it, which makes me stop bothering to look at all.

So I put a small service on one machine. Its only job is to take file paths from other scripts and send the files to my Telegram chat. I have been using it for several months and like it a lot. This article covers both the structure and the Bot API limits to know before writing it. All tokens and chat IDs in the examples are made up.

Scope: outbound only, no inbound path

I need to say this first because it is central to the design. I previously wrote about Setting up a Telegram Bot to monitor a DA Server with a small blast radius. That bot accepts commands from chat to operate the server, which is an entirely different thing from this one.

Server monitoring bot

Two-way, with an inbound path

It has to poll or receive webhooks continuously, turn chat messages into commands, and then do something on the machine. That creates a broad surface exposed to the outside world, so permissions, authentication, and the blast radius of every command need consideration.

File delivery channel

One-way, with no inbound path at all

No polling, no webhooks, and not a single chat message read. All it does is accept jobs from local scripts and send requests to Telegram's API. Even if someone types a command into the chat, nothing reads it.

The path of a single fileAutomated jobFinishes the file->Local CLItg_send.py->127.0.0.1 serviceLogs every time->Bot APIOutbound HTTPS->My chatPhone alerts immediately

I used to have a two-way version that took messages from chat and injected keystrokes into a program on the machine. It worked for a while, but I eventually stopped using it and removed it because it left an inbound path open all the time while giving me far less benefit than I had expected. So the current version removes the inbound path entirely by design.

The actual Bot API limits to know before writing it

50 MBMaximum size per file
10 MBsendPhoto limit
4096Characters per message
1024Characters per caption

Images are the most common stumbling block. If I send an image with sendPhoto, Telegram always compresses it to a smaller size. Screenshots taken to read small text become unreadable immediately, and images larger than 10 MB are rejected outright. The fix is to use sendDocument instead, which does not change a single byte of the file.

My rule for choosing a delivery method

For images meant to give a general impression, I use sendPhoto because viewing them directly in chat is more convenient. For images where I need to read the details, such as screenshots containing numbers or logs, I always use sendDocument; otherwise, sending them is pointless. I let it decide automatically based on file size, with an option to force it using --as-doc when calling it.

The code that selects the delivery method is very short, but it is the part used most often:

How send_file selects a methodFile exists at path?YesNo file -> error, do not sendNoLarger than 50 MB?YesValueError: over bot limitNoImage, no larger than 10 MB?and no --as-docYessendPhoto (Telegram compresses)NoVideo?and no --as-docYessendVideoNosendDocumentSends every byte unchangedcaption cut to 1024 charactersText cut to 4096 before sending
The decision sequence in send_file. Files that do not match the image or video conditions fall through to sendDocument, which does not compress them at all.
PHOTO_EXT = {".jpg", ".jpeg", ".png", ".webp"}
VIDEO_EXT = {".mp4", ".mov", ".m4v"}
MAX_BYTES = 50 * 1024 * 1024
PHOTO_MAX = 10 * 1024 * 1024

def send_file(path: Path, caption: str = "", as_doc: bool = False) -> dict:
    if not path.is_file():
        raise FileNotFoundError(str(path))
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise ValueError(f"{path.name} is {size/1048576:.1f} MB, over the 50 MB bot limit")
    ext = path.suffix.lower()
    if not as_doc and ext in PHOTO_EXT and size <= PHOTO_MAX:
        method, field = "sendPhoto", "photo"
    elif not as_doc and ext in VIDEO_EXT:
        method, field = "sendVideo", "video"
    else:
        method, field = "sendDocument", "document"
    mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    with path.open("rb") as fh:
        res = tg(method, {"chat_id": CHAT_ID, "caption": caption[:1024]},
                 {field: (path.name, fh, mime)})
    log(f"{method} {path.name} ({size/1024:.0f} KB)")
    return res

The point to watch is that caption[:1024] and text[:4096] must truncate the text on my side. If I send text over the limit, the API returns 400 and the file is not sent at all, even though there is nothing wrong with the file itself. The whole attempt fails just because the text is too long.

The local receiver: bind only to 127.0.0.1

The service is a small HTTP server built entirely with http.server from the stdlib, listening on a single port at 127.0.0.1. It has just three endpoints: /health, /message, and /file.

class Handler(BaseHTTPRequestHandler):
    def _authed(self) -> bool:
        return self.headers.get("X-Token") == API_TOKEN

    def do_POST(self):
        route = self.path.split("?")[0]
        if not self._authed():
            return self._reply(401, {"ok": False, "error": "bad or missing X-Token"})
        body = self._body()
        if route == "/message":
            text = body.get("text") or ""
            if not text.strip():
                return self._reply(400, {"ok": False, "error": "text is empty"})
            res = send_message(text)
            return self._reply(200, {"ok": True, "message_id": res.get("message_id")})
        if route == "/file":
            p = body.get("path")
            if not p:
                return self._reply(400, {"ok": False, "error": "path is required"})
            res = send_file(Path(p), body.get("caption", ""), bool(body.get("as_doc")))
            return self._reply(200, {"ok": True, "message_id": res.get("message_id")})
        return self._reply(404, {"ok": False, "error": "unknown endpoint"})

def main() -> None:
    srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)      # ห้ามเป็น 0.0.0.0
    srv.serve_forever()

All secrets live in a single configuration file outside the code:

{
  "bot_token": "<bot_token ของคุณ>",
  "chat_id":   "<หมายเลขแชทของคุณ>",
  "api_token": "<สตริงสุ่มยาว ๆ สำหรับ X-Token>",
  "port": 8787
}
Three security points not to miss

First, bind only to 127.0.0.1. Do not accidentally use 0.0.0.0, or the entire LAN can send requests to it. Second, still require X-Token before doing any work, even though it only listens locally, because other programs on the same machine can send requests too. Third, keep the configuration file outside any folder tracked by git, and never put it in a folder a web server can serve. If it leaks, someone can send anything they want to my chat.

A CLI that still works when the service is down

I do not let other scripts send HTTP requests directly; they all call a single CLI instead. The reason is that the CLI has a fallback. If the local service is not running, it sends requests directly to the API itself, so a job about to report its results does not fail because the service is down.

def service_up(c: dict) -> bool:
    try:
        return requests.get(service_url(c) + "/health", timeout=3).json().get("ok") is True
    except Exception:
        return False

up = False if args.direct else service_up(c)
for f in args.files:
    p = Path(f)
    if not p.is_file():
        sys.exit(f"not a file: {p}")
    if up:
        svc_post(c, "/file", {"path": str(p.resolve()),
                              "caption": args.caption, "as_doc": args.as_doc})
    else:
        direct_file(c, p, args.caption, args.as_doc)      # ทางถอย ยิงตรง
    print(f"[OK] {p.name} ({p.stat().st_size/1024:.0f} KB)")

Actual usage looks like this, which is the only thing I need to remember:

PY="$USERPROFILE/anaconda3/envs/ml313/python.exe"
"$PY" ~/.claude/tools/tg_send.py --check                      # เช็กบริการ + บอท
"$PY" ~/.claude/tools/tg_send.py -m "งานเมื่อคืนเสร็จแล้ว"
"$PY" ~/.claude/tools/tg_send.py out.pdf shot.png -c "รายงาน"
"$PY" ~/.claude/tools/tg_send.py big.png --as-doc             # ไม่ให้บีบรูป

Connecting it to an automated job takes just one line at the end, after the check that decides whether the job passed or failed:

@echo off
set PY=%USERPROFILE%\anaconda3\envs\ml313\python.exe
"%PY%" C:\jobs\build_report.py > C:\jobs\out\report.log 2>&1
if errorlevel 1 (
  "%PY%" %USERPROFILE%\.claude\tools\tg_send.py C:\jobs\out\report.log -c "งานล้ม ดู log"
) else (
  "%PY%" %USERPROFILE%\.claude\tools\tg_send.py C:\jobs\out\report.pdf -c "รายงานประจำวัน"
)

The Thai text trap when calling curl on Windows

This cost me quite a while before I realized the code was not at fault. If I call the /message endpoint with curl and put Thai text directly in --data from a command window on this machine, the message that reaches the chat is completely garbled.

The cause is the console codepage, not the service

Thai characters are converted according to the command window's codepage before curl even sees them, so the service receives bytes that are already corrupted. Setting charset=utf-8 everywhere does not help. The fix is to keep Thai text out of the command line: write it to a UTF-8 file first and have curl read the file, or use the CLI, which already handles this.

# ผิด - ข้อความไทยผ่านบรรทัดคำสั่งแล้วเสียตั้งแต่ต้นทาง
curl -X POST http://127.0.0.1:8787/message \
     -H "X-Token: $TOKEN" --data '{"text":"งานเสร็จแล้ว"}'

# ถูก - เขียนเป็นไฟล์ UTF-8 ก่อน แล้วส่งไบต์ดิบ
printf '{"text":"งานเสร็จแล้ว"}' > /tmp/msg.json
curl -X POST http://127.0.0.1:8787/message \
     -H "X-Token: $TOKEN" -H "Content-Type: application/json; charset=utf-8" \
     --data-binary @/tmp/msg.json

# เช็กสุขภาพ ไม่ต้องใช้ token
curl -s http://127.0.0.1:8787/health

Start it at login and keep it visible

I did not make it a service hidden in the background. Instead, it opens a console window with a clear title when I log in. The reason is that I want to see that it is still there, and I want to watch the logs scroll when something is sent. Every successful delivery is also written to a log file, so I can look back and see what has already been sent.

@echo off
title Telegram Local API :8787
taskkill /f /fi "WINDOWTITLE eq Telegram Local API :8787" >nul 2>&1
"%USERPROFILE%\anaconda3\envs\ml313\python.exe" "%USERPROFILE%\.claude\tools\tg_local_api.py"

Put a shortcut to this bat file in the Startup folder, and that is it. There is no need to go through the trouble of registering it as a service.

  1. Create a bot with BotFather and store the token in a configuration file outside the code.
  2. Send your own bot one message first. Bots cannot start a chat with you.
  3. Write a service that binds only to 127.0.0.1 and checks X-Token on every request.
  4. Give the CLI a fallback that sends directly when the service is not running, so the job does not fail along with it.
  5. Truncate captions to 1024 and messages to 4096 on your side. Do not wait for the API to reject them.

The second item above matters more than it might seem. Telegram bots cannot start a conversation with anyone. The recipient has to send them a message at least once first. During the initial setup, I spent a while puzzled by chat not found because I had never opened a conversation with my own bot.

Conclusion

The setup I use has just three pieces: a small local service listening on localhost, a CLI with a fallback, and a configuration file that keeps secrets in one place. Every automated job on the machine uses the same CLI, so the logs are collected in one place too.

Personally, I think the biggest payoff from doing this is not convenience, but that I have removed the inbound path entirely. An outbound-only channel can go wrong in just one way: sending out the wrong thing, which is far less damaging than a channel that lets someone send commands into my machine. Anyone who wants to make it two-way is welcome to, but if the goal is simply "I want the file on my phone," one-way really is enough.

I am now thinking about adding an internal queue in case the internet connection drops just as a job finishes, so it can retry automatically when the connection returns. If I have missed anything in the explanation or made any typos, I will come back and fix them later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first