EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / ส่งไฟล์จากงานอัตโนมัติเข้า Telegram กับเพดาน 50 MB ของ Bot API
Linux

ส่งไฟล์จากงานอัตโนมัติเข้า Telegram กับเพดาน 50 MB ของ Bot API

โดย Ton-Or ·24 ก.ย. 2026·อ่าน 8 นาที
ส่งไฟล์จากงานอัตโนมัติเข้า Telegram กับเพดาน 50 MB ของ Bot API

งานอัตโนมัติที่ทำไฟล์เสร็จแล้วทิ้งไว้ในโฟลเดอร์บนเครื่องที่บ้าน มีค่าเท่ากับไม่ได้ทำถ้าตอนที่อยากดูเราไม่ได้นั่งอยู่หน้าเครื่องนั้น ผมเลยต่อขาสุดท้ายให้มันสั้นที่สุด คือส่งไฟล์เข้าแชทของตัวเองทันทีที่ทำเสร็จ

พอดีงานอัตโนมัติบนเครื่องที่บ้านผมมีหลายตัวที่ผลลัพธ์เป็นไฟล์ ทั้งรายงานสรุปเป็น PDF ภาพที่แคปไว้ตรวจงาน และไฟล์ข้อมูลที่ต้องเอาไปดูต่อ ปัญหาคือทุกอย่างไปกองอยู่ในโฟลเดอร์บนเครื่องนั้น เวลาผมไม่อยู่บ้านแล้วอยากดูก็ต้องรีโมตเข้าไปหยิบ ซึ่งทำให้ขี้เกียจดูไปเลย

ผมเลยวางบริการเล็ก ๆ ไว้ในเครื่องตัวหนึ่ง หน้าที่เดียวของมันคือรับพาธไฟล์จากสคริปต์อื่นแล้วส่งเข้าแชท Telegram ของผม ใช้มาหลายเดือนแล้วชอบมาก บทความนี้เก็บทั้งโครงและเพดานของ Bot API ที่ต้องรู้ก่อนเขียนครับ ค่า token กับหมายเลขแชทในตัวอย่างเป็นค่าสมมติทั้งหมด

ขอบเขต: ออกทางเดียว ไม่มีขาเข้า

ข้อนี้ต้องพูดก่อนเลยเพราะเป็นหัวใจของการออกแบบ ผมเคยเขียนเรื่อง วาง Telegram Bot เฝ้า DA Server แบบ blast radius เล็ก ไว้แล้ว ตัวนั้นเป็นบอทที่รับคำสั่ง จากแชทมาสั่งงานเซิร์ฟเวอร์ ซึ่งเป็นคนละเรื่องกับตัวนี้โดยสิ้นเชิง

บอทเฝ้าเซิร์ฟเวอร์

สองทาง มีขาเข้า

ต้อง poll หรือรับ webhook ตลอดเวลา แปลข้อความจากแชทเป็นคำสั่ง แล้วไปทำอะไรบางอย่างบนเครื่อง ผิวสัมผัสกับโลกภายนอกกว้าง ต้องคิดเรื่องสิทธิ์ การยืนยันตัวตน และขอบเขตความเสียหายทุกคำสั่ง

ช่องทางส่งไฟล์

ทางเดียว ไม่มีขาเข้าเลย

ไม่ poll ไม่รับ webhook ไม่อ่านข้อความในแชทแม้แต่ข้อความเดียว สิ่งเดียวที่ทำคือรับงานจากสคริปต์ในเครื่องแล้วยิงออกไปหา API ของ Telegram ต่อให้มีคนพิมพ์คำสั่งเข้ามาในแชทก็ไม่มีอะไรอ่านมัน

ทางเดินของไฟล์หนึ่งไฟล์งานอัตโนมัติทำไฟล์เสร็จ->CLI ในเครื่องtg_send.py->บริการ 127.0.0.1บันทึก log ทุกครั้ง->Bot APIHTTPS ขาออก->แชทของผมมือถือเด้งเลย

ผมเคยมีตัวเก่าที่ทำสองทาง คือรับข้อความจากแชทแล้วยัดคีย์เข้าไปในโปรแกรมบนเครื่อง ใช้ได้อยู่พักหนึ่งแต่สุดท้ายเลิกใช้แล้วรื้อทิ้ง เพราะมันเป็นทางเข้าที่เปิดค้างไว้ตลอดเวลาโดยที่ผมได้ประโยชน์จากมันน้อยกว่าที่คิดไว้มาก ตัวปัจจุบันเลยตัดขาเข้าออกทั้งหมดตั้งแต่ออกแบบ

เพดานจริงของ Bot API ที่ต้องรู้ก่อนเขียน

50 MBขนาดสูงสุดต่อไฟล์
10 MBเพดานของ sendPhoto
4096ตัวอักษรต่อข้อความ
1024ตัวอักษรของ caption

เรื่องที่พลาดกันบ่อยที่สุดคือรูป ถ้าส่งรูปด้วย sendPhoto ฝั่ง Telegram จะบีบให้เล็กลงเสมอ ภาพหน้าจอที่แคปมาเพื่อดูตัวหนังสือเล็ก ๆ จะอ่านไม่ออกทันที และถ้ารูปใหญ่เกิน 10 MB มันจะปฏิเสธไปเลย ทางแก้คือส่งเป็น sendDocument แทน ซึ่งไม่แตะไฟล์เลยแม้แต่ไบต์เดียว

กฎที่ผมใช้เลือกวิธีส่ง

รูปที่ดูเอาบรรยากาศ ส่งเป็น sendPhoto เพราะเปิดดูในแชทได้เลยสะดวกกว่า ส่วนรูปที่ต้องอ่านรายละเอียดในภาพ เช่นภาพหน้าจอที่มีตัวเลขหรือ log ให้ส่งเป็น sendDocument ทุกครั้ง ไม่งั้นเสียเที่ยว ผมทำให้มันตัดสินเองตามขนาดไฟล์ แล้วเปิดทางให้บังคับด้วย --as-doc ตอนเรียก

โค้ดส่วนที่เลือกวิธีส่งสั้นมาก แต่เป็นส่วนที่ใช้บ่อยที่สุด:

ฟังก์ชัน send_file เลือกวิธีส่งอย่างไรมีไฟล์ตามพาธไหมใช่ไม่มีไฟล์ -> error ไม่ส่งไม่ขนาดเกิน 50 MB ไหมใช่ValueError เกินเพดานบอตไม่เป็นรูป ไม่เกิน 10 MB ?และไม่สั่ง --as-docใช่sendPhoto (Telegram บีบรูป)ไม่เป็นวิดีโอ ?และไม่สั่ง --as-docใช่sendVideoไม่sendDocumentไม่แตะไฟล์ ส่งทั้งไบต์caption ตัดที่ 1024 ตัวอักษรข้อความตัดที่ 4096 ก่อนส่ง
ลำดับการตัดสินใจของ send_file ไฟล์ที่ไม่ตรงเงื่อนไขรูปหรือวิดีโอจะตกไปเป็น sendDocument ซึ่งไม่บีบไฟล์เลย
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

จุดที่ต้องระวังคือ caption[:1024] กับ text[:4096] ต้องตัดเองตั้งแต่ฝั่งเรา ถ้าปล่อยให้เกินแล้วส่งไป API จะตอบ 400 กลับมาแล้วไฟล์ไม่ถูกส่งเลยทั้งที่ตัวไฟล์ไม่มีปัญหาอะไร เสียเที่ยวเพราะข้อความยาวเกินอย่างเดียว

ตัวรับในเครื่อง: ผูกกับ 127.0.0.1 เท่านั้น

ตัวบริการเป็น HTTP server เล็ก ๆ จาก http.server ใน stdlib ล้วน ฟังอยู่ที่ 127.0.0.1 พอร์ตเดียว มีสามปลายทางเท่านั้นคือ /health, /message และ /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()

ความลับทั้งหมดอยู่ในไฟล์ตั้งค่าไฟล์เดียวข้างนอกโค้ด:

{
  "bot_token": "<bot_token ของคุณ>",
  "chat_id":   "<หมายเลขแชทของคุณ>",
  "api_token": "<สตริงสุ่มยาว ๆ สำหรับ X-Token>",
  "port": 8787
}
สามข้อที่ห้ามพลาดเรื่องความปลอดภัย

หนึ่ง ผูกกับ 127.0.0.1 เท่านั้น อย่าเผลอเป็น 0.0.0.0 ไม่งั้นทั้งวงแลนยิงเข้ามาได้ สอง ยังต้องมี X-Token ถึงจะยอมทำงาน ถึงจะฟังแค่ในเครื่อง เพราะโปรแกรมอื่นในเครื่องเดียวกันก็ยิงเข้ามาได้เหมือนกัน สาม ไฟล์ตั้งค่าต้องอยู่นอกโฟลเดอร์ที่เข้า git และห้ามอยู่ในโฟลเดอร์ที่เว็บเซิร์ฟเวอร์เสิร์ฟได้ ถ้าหลุดออกไปคือมีคนส่งอะไรเข้าแชทเราก็ได้

CLI ที่ยังทำงานได้ตอนบริการล่ม

ผมไม่ให้สคริปต์อื่นยิง HTTP ตรง ๆ แต่ให้เรียกผ่าน CLI ตัวเดียว เหตุผลคือ CLI มีทางถอย ถ้าบริการในเครื่องไม่ได้เปิดอยู่ มันจะยิงตรงไปที่ API เอง งานที่กำลังจะรายงานผลเลยไม่ตายเพราะบริการล่ม

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

เรียกใช้จริงหน้าตาแบบนี้ ซึ่งเป็นสิ่งเดียวที่ผมต้องจำ:

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             # ไม่ให้บีบรูป

ต่อเข้ากับงานอัตโนมัติก็แค่บรรทัดเดียวตอนจบ อยู่หลังด่านตัดสินว่างานผ่านหรือไม่ผ่าน:

@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 "รายงานประจำวัน"
)

กับดักภาษาไทยตอนเรียกผ่าน curl บน Windows

อันนี้เสียเวลาผมไปพักใหญ่กว่าจะรู้ว่าไม่ใช่ความผิดของโค้ด ถ้าเรียกปลายทาง /message ด้วย curl แล้ววางข้อความไทยลงใน --data ตรง ๆ จากหน้าต่างคำสั่งบนเครื่องนี้ ข้อความที่ไปถึงแชทจะเป็นตัวยึกยือทั้งหมด

ต้นเหตุอยู่ที่ codepage ของคอนโซล ไม่ใช่ที่บริการ

ตัวอักษรไทยโดนแปลงตาม codepage ของหน้าต่างคำสั่งตั้งแต่ก่อนที่ curl จะได้เห็นมัน ฝั่งบริการเลยได้ไบต์ที่เสียมาแล้ว ต่อให้ตั้ง charset=utf-8 ครบทุกที่ก็ไม่ช่วย ทางแก้คืออย่าให้ข้อความไทยผ่านบรรทัดคำสั่ง เขียนลงไฟล์ UTF-8 ก่อนแล้วให้ curl อ่านจากไฟล์ หรือเรียกผ่าน CLI ซึ่งจัดการเรื่องนี้ให้อยู่แล้ว

# ผิด - ข้อความไทยผ่านบรรทัดคำสั่งแล้วเสียตั้งแต่ต้นทาง
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

ให้มันเปิดเองตอนล็อกอิน และเห็นได้ว่ายังอยู่

ผมไม่ได้ทำเป็น service ที่ซ่อนอยู่เบื้องหลัง แต่ให้มันเปิดเป็นหน้าต่างคอนโซลที่มีชื่อชัดเจนตอนล็อกอิน เหตุผลคือผมอยากเห็นด้วยตาว่ามันยังอยู่ และอยากเห็น log ไหลตอนมีอะไรถูกส่งออกไป ทุกครั้งที่ส่งสำเร็จมันเขียนลงไฟล์ log ด้วย เลยไล่ย้อนได้ว่าอะไรถูกส่งออกไปแล้วบ้าง

@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"

เอา shortcut ของ bat ตัวนี้ไปวางในโฟลเดอร์ Startup แล้วจบ ไม่ต้องลงทะเบียนเป็น service ให้ยุ่งยาก

  1. สร้างบอทกับ BotFather เก็บ token ไว้ในไฟล์ตั้งค่านอกโค้ด
  2. ทักหาบอทของตัวเองหนึ่งข้อความก่อน บอทเริ่มแชทกับเราเองไม่ได้
  3. เขียนบริการที่ผูก 127.0.0.1 อย่างเดียว และตรวจ X-Token ทุกคำขอ
  4. ให้ CLI มีทางถอยยิงตรงเมื่อบริการไม่ได้เปิด งานจะได้ไม่ตายตาม
  5. ตัด caption ที่ 1024 และข้อความที่ 4096 ตั้งแต่ฝั่งเรา อย่ารอให้ API ปฏิเสธ

ข้อที่สองในรายการข้างบนสำคัญกว่าที่คิด บอทของ Telegram เริ่มบทสนทนากับใครเองไม่ได้ ต้องให้ปลายทางทักมาก่อนอย่างน้อยหนึ่งครั้ง ตอนตั้งครั้งแรกผมนั่งงงกับ chat not found อยู่พักหนึ่งเพราะยังไม่เคยกดคุยกับบอทตัวเองเลย

สรุป

โครงที่ผมใช้อยู่มีแค่สามชิ้นคือ บริการเล็ก ๆ ในเครื่องที่ฟัง localhost, CLI ที่มีทางถอย และไฟล์ตั้งค่าที่เก็บความลับไว้ที่เดียว งานอัตโนมัติทุกตัวบนเครื่องเรียกใช้ผ่าน CLI ตัวเดียวกันหมด เลยได้ log รวมที่เดียวด้วย

ส่วนตัวผมคิดว่าจุดที่คุ้มที่สุดของการทำแบบนี้ไม่ใช่ความสะดวก แต่เป็นการที่ผมตัดขาเข้าออกไปทั้งหมด ช่องทางที่ออกได้อย่างเดียวจะพลาดได้แค่ทางเดียวคือส่งของออกไปผิด ซึ่งเสียหายน้อยกว่าช่องทางที่มีคนส่งคำสั่งเข้ามาถึงเครื่องเราได้เยอะมาก ใครจะทำเป็นสองทางก็ตามสะดวกครับ แต่ถ้าเป้าหมายมีแค่ "อยากได้ไฟล์ลงมือถือ" ทางเดียวพอแล้วจริง ๆ

ตอนนี้ผมกำลังคิดจะเพิ่มคิวไว้ข้างในเผื่อกรณีที่เน็ตขาดตอนงานเสร็จพอดี จะได้ส่งซ้ำเองเมื่อเน็ตกลับมา ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย