การสร้าง AI Assistant เฉพาะโปรเจกต์ผ่าน Telegram
AI assistant ที่ถูก scope ไว้สำหรับ client mandate เดียวนั้นมีโครงสร้างที่แตกต่างจาก AI tool ระดับองค์กรโดยพื้นฐาน ขอบเขต โมเดลความจำ secrets และ lifecycle การ deploy ล้วนแตกต่างกัน คู่มือนี้อธิบายรูปแบบ project-specific AI assistant ที่ tva ใช้งาน ได้แก่ Telegram bot หนึ่งตัวต่อ consulting mandate หนึ่งงาน โดยแต่ละตัวมี Claude Code CLI instance ของตัวเองพร้อม OAuth แบบ persistent มี workspace เป็นของตัวเอง และมี allow-list ของผู้เข้าร่วมเป็นของตัวเอง รูปแบบนี้สามารถ clone เป็น template ข้ามงานได้ และ stack ทั้งหมดรันใน Docker container เดียว
คู่มือนี้เขียนขึ้นเพื่อให้นักพัฒนา (หรือ LLM) อ่านตามลำดับแล้วสามารถ reproduce การตั้งค่าได้ตั้งแต่ต้น ทุก version ถูก pin ไว้ ทุกตัวเลือกการ configuration มีเหตุผลรองรับ และทุกการตัดสินใจด้าน architecture ระบุทางเลือกที่ปฏิเสธพร้อมเหตุผล
สิ่งที่คุณต้องมี
- Linux server ที่มี Docker Engine 29.x และ Docker Compose v2 (เราใช้ Hetzner Cloud VPS host ที่รอง container ได้ก็ใช้ได้)
- Anthropic Pro หรือ Max subscription (คู่มือนี้ใช้ OAuth-subscription auth ไม่ใช่ API-key billing)
- Telegram bot ที่ลงทะเบียนผ่าน
@BotFatherพร้อม token - Telegram user ID ของทุกคนที่คุณต้องการให้เข้าถึง assistant ได้ (ผ่าน
@userinfobot) - เบราว์เซอร์บนเครื่องของคุณสำหรับ OAuth flow ครั้งแรก
- ความเข้าใจชัดเจนว่า consulting mandate ที่ instance นี้จะรองรับคืออะไร
สิ่งที่จะได้จากการสร้างนี้
- Docker container เดียวที่รวม Python Telegram bot และ Claude Code CLI ไว้ใน runtime เดียวกัน
- OAuth เทียบกับ Claude Max subscription พร้อม credentials ที่คงอยู่แม้ container จะ restart
- Allow-list middleware ที่ตัดข้อความจากคนที่ไม่อยู่ในรายการออกอย่างเงียบๆ ทั้งใน DM และกลุ่ม
- Persistent workspace ที่ assistant เก็บรักษาไฟล์ CLAUDE.md และ notes/ ของตัวเองไว้ตลอดเวลา
- File-intake สำหรับไฟล์แนบจาก Telegram (PDF, รูปภาพ, บันทึกเสียง ฯลฯ) ไปยัง
/workspace/incoming/ - Conversation log ในรูปแบบ Markdown อ่านได้โดยมนุษย์ และ assistant เข้าถึงได้ผ่าน Read-Tool
- Identity layer (
HERMES_PROJECT_NAME,HERMES_INSTANCE_ID) ที่ทำให้ stack clone ไปยัง mandate อื่นได้ด้วยการเปลี่ยน env var สองตัว
โมเดล Project-Instance: เหตุใด Per-Mandate จึงดีกว่า Org-Wide
สัญชาตญาณเริ่มต้นเมื่อสร้าง internal AI tool คือทำให้ครอบคลุมทั้งองค์กร: bot ตัวเดียว workspace เดียว สมาชิกทุกคนในทีมเข้าถึงได้ รูปแบบนี้ใช้ได้สำหรับ use case ที่ความเสี่ยงต่ำ (Slack bot ที่สรุปเอกสาร, internal Q&A tool) แต่จะพังอย่างรวดเร็วสำหรับงาน consulting ซึ่งแต่ละ mandate มีขอบเขตความลับของตัวเอง มี stakeholder ของตัวเอง มี knowledge base ของตัวเอง และมี deadline ของตัวเอง
โมเดล project-instance กลับหัวแนวคิดนี้ Bot หนึ่งตัวต่อหนึ่ง mandate Workspace หนึ่งชุดต่อหนึ่ง mandate Allow-list หนึ่งรายการต่อหนึ่ง mandate ความจำของ assistant ถูก scope ไว้กับโปรเจกต์ ไม่ใช่กับ operator เมื่อ mandate สิ้นสุด instance สามารถ archive หรือลบทิ้งได้โดยไม่กระทบสิ่งอื่น
ในทางปฏิบัติ:
- Telegram bot มี username เฉพาะโปรเจกต์ (เช่น
@some_project_assistant_bot) ลงทะเบียนแยกต่างหากกับ BotFather - Docker container มีชื่อเฉพาะโปรเจกต์ (เช่น
some-project-assistant) รันจาก directory เฉพาะโปรเจกต์ (/opt/some-project-assistant/) - OAuth session ถูก scope ไว้กับ instance นี้ โดยแนะนำให้ใช้ Anthropic account แยกต่างหากหากรันหลาย instance เพื่อหลีกเลี่ยงปัญหา refresh-token rotation ชนกัน
- Workspace ที่
/workspace/CLAUDE.mdมีเพียง briefing สำหรับ mandate เฉพาะนี้ - Allow-list มีเพียงผู้เข้าร่วมของ mandate เฉพาะนี้
Environment variable สองตัวทำให้ stack clone เป็น template ได้: HERMES_PROJECT_NAME (ชื่อที่แสดง ใช้ใน system prompt และผลลัพธ์ของ /help) และ HERMES_INSTANCE_ID (slug ที่ใช้ใน directory path และ Claude session identifier) ในการ clone stack สำหรับลูกค้าใหม่ คุณเพียงเปลี่ยน env var สองตัว ลงทะเบียน BotFather bot ใหม่ รัน OAuth login ใหม่ กรอก workspace template และ codebase ทั้งหมดยังคงเหมือนกัน bit ต่อ bit
การตัดสินใจด้าน Architecture เจ็ดข้อก่อนเริ่มเขียนโค้ด
เหตุที่ stack นี้เล็กและคาดเดาได้คือเราตัดสินใจอย่างมีเจตนาเจ็ดครั้งก่อนเขียนโค้ดบรรทัดแรก แต่ละการตัดสินใจมีทางเลือก และทางเลือกเหล่านั้นมีความสำคัญ หาก context ของคุณแตกต่างจากของเรา การเลือกสาขาอื่นในข้อใดข้อหนึ่งจะให้ stack ที่แตกต่าง (และอาจดีกว่า) เราระบุการตัดสินใจ ทางเลือกที่พิจารณา และ trade-off ที่นำไปสู่ทางเลือกของเรา
การตัดสินใจที่ 1: ระดับละเอียดของ Memory
ทางเลือกคือระหว่าง global assistant memory (Claude session เดียวสำหรับทุก chat, assistant จำทุกอย่างข้ามทั้ง DM และกลุ่ม) กับ per-chat memory (แต่ละ chat มี session แยกของตัวเอง พร้อมขอบเขต privacy เข้มงวดระหว่าง DM และการสนทนาในกลุ่ม)
เราเลือก global เหตุผลคือ consulting assistant ได้ประโยชน์จากการเชื่อมโยงข้อมูลข้ามการสนทนา สิ่งที่คุยกันใน DM เรื่องการประเมิน vendor ส่งผลต่อการสนทนาในกลุ่มเรื่องสัญญาของ vendor นั้น Per-chat memory จะบังคับให้ operator ต้องป้อน context ซ้ำ และ assistant จะรู้สึกขาดการเชื่อมโยง
ต้นทุนมีอยู่จริง: ไม่มีขอบเขต privacy ระหว่าง DM และกลุ่ม สิ่งที่กล่าวถึงใน DM อาจถูกเรียกคืนในการตอบสนองในกลุ่ม นี่เป็นทางเลือกที่ชัดเจนและมีการบันทึกไว้ ไม่ใช่ผลข้างเคียง สำหรับ use case ที่แตกต่าง (เช่น HR bot ที่การเปิดเผยข้อมูลส่วนตัวต้องเก็บเป็นความลับ) per-chat memory คือคำตอบที่ถูกต้อง
การ implement: flag --continue ของ Claude Code CLI พร้อม working directory คงที่ Session file อยู่ที่ ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl คงอยู่ผ่าน bind-mount และ resume ได้ในทุกครั้งที่เรียก claude จาก working directory เดิม
การตัดสินใจที่ 2: Subscription OAuth กับ API Key
คุณสามารถขับเคลื่อน Claude Code CLI ได้สองวิธี: ด้วย Pro/Max subscription ของ operator (OAuth-based, ไม่มีการเรียกเก็บเงินต่อ call) หรือด้วย Anthropic API key (จ่ายต่อ token) ค่าเริ่มต้นคือ subscription กับดักคือ environment variable หลายตัวจะสลับไปใช้ API-key billing โดยเงียบๆ หากตั้งค่าในสภาพแวดล้อมหลัก
ตามเอกสาร authentication ของ Anthropic ลำดับการ resolve คือ: cloud-provider flags ของ Bedrock/Vertex/Foundry ก่อน จากนั้น ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, apiKeyHelper, CLAUDE_CODE_OAUTH_TOKEN และสุดท้าย subscription OAuth จาก /login หากตัวเลือกที่มี priority สูงกว่าตั้งค่าไว้ แม้เป็น empty string ในบาง shell CLI จะไม่ fallthrough ไปยัง subscription auth
เพื่อรับประกันการทำงาน OAuth เท่านั้น ให้ตั้งค่า environment variable ทั้งหกตัวเป็น empty string อย่างชัดเจนใน block environment: ของ compose file นี่เป็นการป้องกันที่มีต้นทุนต่ำ
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
Trade-off: subscription auth มี refresh-token rotation แบบ single-use ที่จะขัดแย้งหาก Anthropic account เดียวกันถูกใช้โดยทั้ง container และ laptop ของ operator พร้อมกัน หากใช้พร้อมกัน คุณจะพบ logout แบบสุ่ม สำหรับ assistant เฉพาะ Anthropic account เฉพาะคือคำตอบที่สะอาดกว่า สำหรับ context เปรียบเทียบเกี่ยวกับ AI tool ต่างๆ และ auth model ของพวกเขา ดูที่ การเปรียบเทียบอย่างตรงไปตรงมาของ Claude Code, Cursor และ CLI agent อื่นๆ
การตัดสินใจที่ 3: Container เดียวหรือสองตัว
Bot ต้องการ Telegram framework (Python, aiogram) Claude engine ต้องการ Node และ @anthropic-ai/claude-code CLI คุณสามารถรันแยกเป็นสอง container (เช่น bot ใน Python container, claude ใน Node container มี IPC ระหว่างกัน) หรือรวมเป็นหนึ่ง
แนวทางสอง container มีความสะอาดทางโครงสร้างมากกว่าแต่นำมาซึ่งปัญหา IPC Bot ต้องเรียก claude เป็น subprocess ซึ่งหมายความว่าต้องการ Docker socket access ไปยัง container อีกตัว (ความเสี่ยง privilege escalation) หรือ custom file-based IPC layer (latency เพิ่มและโค้ดเพิ่ม) ทั้งสองไม่น่าดึงดูด
แนวทาง container เดียวแลก container-purity กับความเรียบง่ายในการ operate Image เดียว OAuth session เดียว environment variable ชุดเดียว bind-mount layout ชุดเดียว Image มีขนาด ~700 MB แทนที่จะเป็น ~120 MB แต่ disk แทบไม่เคยเป็น bottleneck
เราเลือก container เดียว Dockerfile ติดตั้งทั้ง Python stack (slim base + pip) และ Node stack (NodeSource keyring + claude-code) ตามลำดับ expose entrypoint เดียว และ bot เรียก claude ผ่าน asyncio.create_subprocess_exec ไม่มี IPC ไม่มี socket proxy ไม่มี inter-container networking
การตัดสินใจที่ 4: Workspace Bootstrap
Assistant ต้องการ knowledge base ทางเลือกคือ seed ด้วย project context (เพื่อไม่ต้องป้อนข้อเท็จจริงทุกอย่างผ่าน chat) หรือเริ่มเปล่า (assistant เรียนรู้จาก interaction เท่านั้น)
เรา seed Workspace template ที่ templates/workspace-CLAUDE.md.template มี placeholder section สำหรับ: โปรไฟล์ของ operator, ผู้เข้าร่วมและบทบาทของพวกเขา, background ของ mandate, convention ด้านภาษา และคำแนะนำเรื่องวิธีที่ assistant ควรดูแล notes ตลอดเวลา เมื่อ instance ใหม่ถูก bootstrap template จะถูก copy ไปยัง data/workspace/CLAUDE.md และ placeholder จะถูกกรอก
Assistant จะดูแลไฟล์นั้นด้วยตัวเองผ่าน Write และ Edit tools เมื่อคุณแก้ไขมัน ("client นี้ไม่ได้ใช้คำนั้นแบบนั้น") มันสามารถอัปเดตไฟล์ workspace เพื่อให้การแก้ไขคงอยู่สำหรับ session ในอนาคต ร่วมกับ global session memory นี่ให้ assistant สองชั้นของ state: ระยะสั้นใน Claude session ระยะยาวในไฟล์ workspace ทั้งสองคงอยู่ข้าม container restart ผ่าน bind-mount
การตัดสินใจที่ 5: พฤติกรรมในกลุ่มและ Privacy Mode
Telegram bot ในกลุ่มมีการตั้งค่า privacy mode: ตามค่าเริ่มต้น bot จะเห็นเฉพาะข้อความที่ส่งถึงมันโดยตรง (คำสั่ง, @mention, reply) ข้อความอื่นในกลุ่มจะไม่ถูกส่งให้ bot เลย คุณสามารถปิดสิ่งนี้ใน BotFather (/setprivacy → Disable) ซึ่งเมื่อนั้น bot จะเห็นทุกข้อความในทุกกลุ่มที่มันเป็นสมาชิก
สำหรับ consulting assistant ที่ควรเรียนรู้จากการสนทนากลุ่ม การปิดคือการตั้งค่าที่ถูกต้อง แต่สิ่งนี้ก่อให้เกิดคำถามต่อเนื่อง: bot จะตัดสินใจอย่างไรว่าข้อความไหนควรตอบและข้อความไหนควรแค่ log ไว้?
โมเดล trigger ของเรา: ใน DM ทุกข้อความได้รับการตอบ ในกลุ่ม bot จะตอบเฉพาะ (a) @mention โดยตรงถึง bot, (b) reply ต่อข้อความก่อนหน้าของมัน หรือ (c) ข้อความที่มีคำว่า "Hermes" เป็นคำอิสระ (case-insensitive, word-boundary matched) ข้อความอื่นๆ ทั้งหมดจะถูก log ไปยัง /workspace/conversations/chat-<id>.md แต่ไม่ trigger การเรียก Claude
ซึ่งหมายความว่า assistant มีการอ่านผ่าน group context (สามารถค้นหาไฟล์ log ผ่าน Read tool เมื่อต้องการ background) แต่ไม่สร้าง noise Conversation log ยังเป็น human artifact ที่มีประโยชน์ operator สามารถ cat เพื่อดูประวัติโปรเจกต์ได้
การตัดสินใจที่ 6: Response UX
Claude response อาจใช้เวลา 5–30 วินาทีเมื่อมีการใช้ tool (web fetch, file read, multi-step reasoning) ทางเลือกคือ: buffered (รอ response เต็มแล้วส่งข้อความเดียว) หรือ streamed (แก้ไขข้อความเดียวแบบ progressive เมื่อ token มาถึง)
Streamed เท่กว่าแต่ซับซ้อนกว่า: อัตราการแก้ไขข้อความใน Telegram ถูก throttle แต่ตัวเลข ceiling ที่แน่นอนไม่ได้ถูกระบุไว้เป็นตัวเลขเดียว อัตราการส่งทั่วไป (ขีดจำกัดที่ Telegram เผยแพร่: ประมาณ 30 ข้อความต่อวินาทีโดยรวม ไม่เกินหนึ่งต่อวินาทีต่อ chat 20 ต่อนาทีต่อกลุ่ม) ให้ขอบเขตคร่าวๆ การ edit stream token ต่อ token แบบ naive จะถูก throttle อย่างรวดเร็ว การ implement ต้องการ chunk aggregation, การจัดการ partial-message จาก stream-json output ของ Claude และการ degrade อย่างสง่างามเมื่อ edit ถูก throttle
เราเลือก buffered ขณะที่ response กำลังถูกสร้าง bot จะส่ง typing chat action ทุก 5 วินาที เพื่อให้ผู้ใช้เห็น "กำลังพิมพ์" ใน Telegram client เมื่อ response พร้อม จะถูกส่งออกเป็นหนึ่งหรือหลายข้อความ Response ที่ยาวกว่า 4,000 ตัวอักษรจะถูกแบ่งอัตโนมัติที่ขอบเขต paragraph สุดท้ายก่อนขีดจำกัด พร้อม pause 0.3 วินาทีระหว่างข้อความเพื่อให้อยู่ใน send rate ของ Telegram
การตัดสินใจที่ 7: Strict Allow-List เป็นขอบเขตภายนอก
Telegram bot สามารถเข้าถึงได้โดยใครก็ตามที่ค้นหา username ของมัน หากไม่กรอง ผู้ใช้แบบสุ่มจะค้นพบ bot และลองคำสั่ง สำหรับ consulting assistant นี่เป็นสิ่งที่ยอมรับไม่ได้: assistant มี OAuth credentials ของ operator มีสิทธิ์เข้าถึง workspace สามารถ make tool call ได้ คุณไม่ต้องการคนแปลกหน้าใน loop นี้
Allow-list ถูก implement เป็น aiogram outer-middleware ในระดับ update ซึ่งเป็นจุด interception สูงสุด ก่อน filter resolution และ handler lookup การตรวจสอบอยู่ที่ event_from_user.id (Telegram user ID แบบตัวเลขที่คงที่ต่อผู้ใช้แม้จะเปลี่ยน username) สมาชิก allow-list ถูก configure ผ่าน CSV environment variable (HERMES_ALLOWED_USERS) หากผู้ส่งไม่อยู่ใน set middleware จะ return None โดยไม่เรียก handler: ไม่มี log entry นอกจาก debug-level drop event ไม่มีการเขียน conversation log ไม่มีการเรียก Claude ไม่มีการตอบสนอง
นี่ยังเป็นที่ที่เหมาะสำหรับ operator-must-be-allowed validation: settings loader (ใช้ pydantic-settings) ตรวจสอบว่า HERMES_OPERATOR_ID อยู่ใน HERMES_ALLOWED_USERS เมื่อ startup การ misconfiguration จะทำให้ container crash ทันทีแทนที่จะ lock operator ออกอย่างเงียบๆ
Container Stack
เมื่อตัดสินใจเจ็ดข้อแล้ว stack ก็เกิดขึ้นเองตามธรรมชาติ นี่คือ docker-compose.yml ฉบับสมบูรณ์:
services:
hermes:
build: ./hermes
container_name: project-assistant
restart: unless-stopped
init: true
stop_grace_period: 15s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 512
env_file:
- .env
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
DISABLE_AUTOUPDATER: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TERM: xterm-256color
volumes:
- ./data/claude:/home/hermes/.claude
- ./data/claude.json:/home/hermes/.claude.json
- ./data/bot:/data
- ./data/workspace:/workspace
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ตัวเลือกสำคัญที่ไม่ชัดเจนเมื่ออ่านแบบผ่าน:
init: true— รัน tini เป็น PID 1 เพื่อให้ Python process รับ SIGTERM ได้อย่างสะอาด หากไม่มีสิ่งนี้docker compose stopจะรอ 10 วินาทีแล้ว SIGKILL ทิ้ง ทำให้ bot session ปิดไม่สะอาดstop_grace_period: 15s— นานกว่าpolling_timeoutค่าเริ่มต้นของ aiogram ที่ 10 วินาทีเล็กน้อย ช่วยให้ shutdown hook มีเวลาปิด Telegram session อย่างสะอาด ซึ่งป้องกันTelegramConflictError: terminated by other getUpdates requestเมื่อ container restart เร็วกว่าที่ Telegram จะ release long-poll connection ก่อนหน้าcap_drop: ALLและno-new-privileges:true— container ไม่ต้องการ Linux capability ใดๆ ไม่ต้องการ privilege escalation ทั้งสองถูก tighten ตามค่าเริ่มต้น- ไม่มี
read_only: true— Claude Code เขียนไปยัง~/.claude/,~/.npm/และบางครั้ง/tmp/สำหรับการ self-update Read-only root จะต้องใช้ tmpfs mount จำนวนมากเพื่อชดเชย ไม่คุ้มกับการได้ด้านความปลอดภัย - Two file mount —
./data/claude.json:/home/hermes/.claude.jsonmount ไฟล์เฉพาะ (ไม่ใช่ directory) ไฟล์นี้ต้องมีอยู่บน host ก่อนcompose upโดย init ด้วย{}มิเช่นนั้น Claude Code จะ throw JSON parse error เมื่อ startup
Dockerfile รวมสอง runtime:
FROM python:3.13-slim-trixie
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl file git gnupg locales poppler-utils tmux \
&& locale-gen en_US.UTF-8 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN userdel -r ubuntu 2>/dev/null || true \
&& useradd -m -u 1000 -s /bin/bash hermes \
&& mkdir -p /data /workspace \
&& chown -R hermes:hermes /data /workspace /app
COPY --chown=hermes:hermes src/ ./src/
USER hermes
WORKDIR /app
CMD ["python", "-u", "-m", "src.main"]
Version ที่ pin ไว้ซึ่งคุณควรทราบ:
python:3.13-slim-trixie— Debian 13 base ซึ่งเป็น stable ปัจจุบันaiogram>=3.28,<4.0— bot framework (เรา lock ไว้ที่ 3.28.x ณ เวลาที่เขียน latest ปัจจุบันคือ 3.28.2)pydantic-settings>=2.14,<3.0— settings loaderstructlog>=25.4,<26.0— JSON logging@anthropic-ai/[email protected]— ติดตั้งผ่าน npm global จาก NodeSource Node 20poppler-utils— สำหรับ pdftotext เป็น Read-tool fallback เมื่อ PDF parsing ของ Claude ไม่เหมาะกับไฟล์นั้น
สองสิ่งที่ควรระวังใน Dockerfile นี้:
- บรรทัด
userdel -r ubuntuPython slim base บน Debian 13 ไม่มาพร้อม default UID-1000 user แต่หากคุณเปลี่ยนไปใช้ base image ที่มี (Ubuntu derivative บางตัว) คำสั่งuseradd -u 1000จะ fail ให้ลบ UID-1000 user ที่มีอยู่ก่อนเสมอ - รูปแบบ NodeSource keyring เราหลีกเลี่ยง install script แบบ
curl | bashซึ่ง deprecated และ non-reproducible รูปแบบ keyring + codenamenodistroสามารถ reproduce ได้และ audit-friendly
สำหรับมุมมองเชิงลึกเกี่ยวกับการ harden container ในบริบทของ production stack หลาย service ดูที่ การรัน Docker container กว่าร้อยตัวใน production
Python Stack: ห้า Module ที่ทำงานจริง
โค้ด bot แบ่งออกเป็น module ที่มีโฟกัสชัดเจน ไม่มี module ไหนขนาดใหญ่ ที่ใหญ่ที่สุดมีประมาณ 200 บรรทัด
settings.py— pydantic-settingsBaseSettingsพร้อม customBeforeValidatorสำหรับ comma-separated allow-list Validator จัดการ edge case ที่ pydantic-settings JSON-decode integer เปล่า (allow-list แบบ single-element เช่นHERMES_ALLOWED_USERS=12345กลายเป็นintไม่ใช่list[int]) และแปลงเป็น list ที่มีหนึ่ง elementmiddleware.py—AllowListMiddlewareเป็นdp.update.outer_middlewareการตรวจสอบที่data.get("event_from_user")ใช้ user-context extraction ที่ aiogram มีในตัวtrigger.py—is_trigger(message, bot_id, bot_username)return(True, "DM" | "@mention" | "text_mention" | "reply" | "keyword")หรือ(False, None)การ match keyword ใช้ word-boundary regex (\bhermes\b) เพื่อไม่ให้ substring triggerconversation_log.py— append-only Markdown log ไปยัง/workspace/conversations/chat-<id>.mdทั้งข้อความขาเข้าและขาออกถูก log ไว้file_intake.py— จัดการไฟล์แนบ Telegram แปดประเภท (document, photo, video, audio, voice, animation, sticker, video_note) ดาวน์โหลดไปยัง/workspace/incoming/chat-<id>/<timestamp>-<name>พร้อมขีดจำกัด 20 MB (ขีดจำกัด download ของ Telegram bot-API)hermes_engine.py— wrap Claude Code CLI subprocess ใช้asyncio.create_subprocess_execพร้อมcwd=/workspaceและ--continue(หลังจาก call แรก) เพื่อคง global session ไว้response_pipeline.py— รวม typing-indicator refresh, auto-split และ inter-message delayhandlers.py— command handler สามตัว (/ping,/status,/help) และ default-message handler ที่รัน trigger check และ dispatch ไปยัง engine
การเรียก Claude CLI คือชิ้นส่วนหลัก นี่คือ subprocess call ฉบับสมบูรณ์:
cmd = [
"claude",
"--print",
"--add-dir", "/workspace",
"--dangerously-skip-permissions",
"--append-system-prompt", _build_system_prompt(),
]
if SESSION_MARKER.exists():
cmd.append("--continue")
cmd.append(_build_user_prompt(text, ctx))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd="/workspace",
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
SESSION_MARKER.touch(exist_ok=True)
return stdout.decode("utf-8").strip()
System prompt กำหนด persona ของ assistant, วินัยในการ validate (อย่าอ้าง ให้ validate ผ่าน tool ก่อน), learning loop (เขียนข้อเท็จจริงใหม่ไปยัง /workspace/CLAUDE.md หรือ notes/), รูปแบบ Telegram output (plain text ไม่ใช่ Markdown เพราะ default mode ของ Telegram render Markdown ไม่ได้ทุก context) และหมายเหตุเรื่อง conversation log ที่พร้อมใช้ read-on-demand
User prompt ห่อข้อความจริงพร้อม context metadata: แหล่งที่มาของ chat (DM กับ X หรือกลุ่ม Y ที่มีสมาชิก A, B, C), identity ของผู้ส่ง, timestamp, เหตุผลของ trigger และ file path ของ conversation log สำหรับ chat นั้น ช่วยให้ assistant ตัดสินใจได้ว่าควรค้นหา group context ก่อนตอบหรือไม่
ขั้นตอนการ Bootstrap Instance ใหม่
สมมติว่า codebase อยู่ใน Git repo และคุณมี server พร้อม Docker ติดตั้งแล้ว นี่คือ bootstrap ฉบับสมบูรณ์ ให้แทนค่าของคุณเองสำหรับ <instance-id> และ <project-name>
ขั้นตอนที่ 1: ลงทะเบียน bot กับ BotFather
- ใน Telegram ส่งข้อความหา
@BotFather /newbot→ ตั้งชื่อและ username (เช่นsome_project_assistant_bot)- บันทึก token ที่ BotFather return มา
/setprivacy→ เลือก bot ของคุณ → Disable (เพื่อให้ bot เห็นข้อความทุกอย่างในกลุ่ม ไม่ใช่แค่คำสั่งและ mention)- Optional:
/setcommandsพร้อมping,status,help
ขั้นตอนที่ 2: Clone repo และเตรียม directory บน server
sudo mkdir -p /opt/<instance-id>
sudo chown $USER /opt/<instance-id>
git clone <repo-url> /opt/<instance-id>
cd /opt/<instance-id>
mkdir -p data/claude data/bot data/workspace/{notes,conversations,incoming}
touch data/claude.json
sudo chown -R 1000:1000 data/
ขั้นตอน chown นี้สำคัญมากและมักถูกมองข้าม Docker สร้าง bind-mount source directory ที่ขาดหายไปเป็น root-owned หากข้ามขั้นตอนนี้ container user (UID 1000) จะไม่สามารถเขียนได้และ OAuth login จะ fail อย่างเงียบๆ ตรวจสอบด้วย stat -c "%a %u:%g" data/claude — ต้องการ 1000:1000 ไม่ใช่ 0:0
ขั้นตอนที่ 3: Init workspace
cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# เปิดไฟล์และแทน placeholder:
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, etc.
ขั้นตอนที่ 4: Configure secrets
cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# แก้ไข .env: TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID
ขั้นตอนที่ 5: Pre-condition — ส่ง /start ให้ bot
Telegram ไม่อนุญาตให้ bot ส่ง DM ถึงผู้ใช้จนกว่าผู้ใช้จะเริ่มต้น chat ครั้งแรก ก่อน container เริ่มครั้งแรก operator ต้องเปิด bot ใน Telegram และส่ง /start Bot จะไม่ตอบ (ไม่มี handler ลงทะเบียนสำหรับ /start) แต่ Telegram จะเปิด DM channel ภายใน หากข้ามขั้นตอนนี้ onboarding DM แรกจะ throw TelegramForbiddenError
ขั้นตอนที่ 6: Build และ start container
docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes
คุณควรเห็น JSON log sequence: startup (พร้อมชื่อโปรเจกต์และจำนวน allowed_users), polling_initialized (พร้อม drop_pending=true), onboarding_sent (DM แรกถึง operator) จากนั้น polling-started events ของ aiogram
ขั้นตอนที่ 7: OAuth login
นี่เป็นขั้นตอน interactive ครั้งเดียว ใน terminal ที่กว้างพอที่จะแสดง OAuth URL ในบรรทัดเดียว (250+ ตัวอักษร — มิเช่นนั้น URL จะตัดบรรทัดและ Cloudflare จะ reject auth ด้วย Unknown scope: us):
docker exec -it project-assistant tmux new-session -s claude
# ภายใน container:
claude
# ภายใน claude REPL:
/login
# เลือก: "Claude Pro or Max subscription"
# Copy URL ที่แสดง เปิดในเบราว์เซอร์ login แล้ว paste
# authorization code กลับไปในเทอร์มินัล
/status # ควรแสดง "Subscription" ไม่ใช่ "API key"
/quit
# Detach tmux ด้วย Ctrl-b d จากนั้นออกจาก docker exec
ขั้นตอนที่ 8: Smoke test ใน Telegram
- DM ถึง bot:
/ping→pong - DM ถึง bot:
/status→ diagnostic output (จำนวน allow-list, session messages, log counts) - DM ถึง bot: คำถาม natural language — assistant ควรตอบโดยใช้ Claude engine
- ส่ง PDF เล็กๆ หรือรูปภาพพร้อม caption — assistant ควรอ้างอิงเนื้อหาของมัน
- ส่งข้อความสองอันติดกันที่อันที่สองอ้างอิงอันแรก — multi-turn memory ควรคงอยู่
ขั้นตอนที่ 9: เพิ่มผู้เข้าร่วมเพิ่มเติม (เมื่อพร้อม)
- ผู้เข้าร่วมแต่ละคนดึง Telegram user ID จาก
@userinfobot - อัปเดต
.env:HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id> docker compose restart hermes- สร้างกลุ่ม Telegram เชิญสมาชิกทุกคนและ bot ทดสอบด้วย
/ping@some_project_assistant_bot
หมายเหตุการ Operate และความเสี่ยงที่ทราบ
Stack ทำงานได้เสถียร แต่มีประเด็นหลายอย่างที่ควรทราบ
Cloudflare WAF บน OAuth refresh auth endpoint ของ Anthropic อยู่หลัง Cloudflare มีปัญหาที่ทราบ (เปิดอยู่ใน repo anthropics/claude-code) ที่ Cloudflare จัดประเภท server IP บางตัวว่าเป็น headless Linux และบล็อก OAuth refresh อย่างถาวร ผู้รายงานพบว่าถูก lockout เป็นสัปดาห์ เส้นทางแก้ไขคือ re-authenticate จาก IP อื่น (residential, VPN) การลดความเสี่ยงคือหลีกเลี่ยง custom user-agent และ aggressive retry loop และพิจารณา claude setup-token (token หนึ่งปีที่สร้างจากเครื่องที่ authenticated แล้วนำไปใส่ใน container ผ่าน CLAUDE_CODE_OAUTH_TOKEN) เป็น fallback หากคุณ operate ใน IP range ที่มีความเสี่ยงสูง
Refresh-token single-use rotation OAuth refresh token ของ Anthropic เป็นแบบ single-use หาก client สองตัว (เช่น laptop และ container ของคุณ) ใช้ account เดียวกันและ refresh พร้อมกัน การ refresh แรกจะ invalidate อีกฝั่ง คำแนะนำในทางปฏิบัติ: Anthropic account เฉพาะต่อ assistant instance หนึ่งตัว หากทำไม่ได้ ยอมรับ re-login ที่อาจเกิดขึ้นและอย่ารัน Claude Code บน laptop และใน assistant พร้อมกัน
กับดัก claude.json ว่างเปล่า หาก data/claude.json เป็นไฟล์ขนาดศูนย์ byte เมื่อ container เริ่มครั้งแรก Claude Code จะ throw Configuration Error: invalid JSON, Unexpected EOF ให้ init ด้วย echo "{}" > data/claude.json ไม่ใช่ touch error สามารถ recover ได้ใน REPL ("Reset with default configuration") แต่ดีกว่าที่จะหลีกเลี่ยงความยุ่งยากนั้น
OAuth URL line-wrap bug (สิ่งที่เราพบเอง) OAuth URL มีความยาวประมาณ 530 ตัวอักษร ใน terminal ที่มีความกว้างปกติมันจะตัดบรรทัดหลายบรรทัด เมื่อคุณ copy output ที่ตัดแล้ว line break จะมาด้วย และหลังจาก URL-encoding scope parameter จะดูเหมือน user:inference us\ner:profile Cloudflare จะเห็น us เป็น scope และ reject ด้วย Invalid OAuth Request — Unknown scope: us ปัญหานี้ยังไม่ถูก track เป็น upstream issue ณ เวลาที่เขียน เราพบเองในการใช้งาน การขยาย terminal ให้กว้าง 250+ ตัวอักษรก่อนรัน claude จะหลีกเลี่ยงได้ หรือคุณสามารถ de-wrap URL ด้วยตนเองใน address bar ของเบราว์เซอร์หลัง paste
Ubuntu UID 1000 conflict Ubuntu base image สมัยใหม่บางตัว โดยเฉพาะ ubuntu:24.04 (Noble) หลัง Canonical OCI rebase มาพร้อมกับ default ubuntu user ที่ UID 1000 หากคุณเปลี่ยน Dockerfile base จาก python:3.13-slim-trixie (ซึ่งไม่มาพร้อม default UID-1000 user) ไปเป็น base ที่มี คำสั่ง useradd -u 1000 hermes จะ fail ด้วย UID 1000 is not unique Dockerfile มี defensive userdel -r ubuntu 2>/dev/null || true ก่อน useradd ด้วยเหตุนี้
Conversation log อาจขยายใหญ่ขึ้น log แบบ append-only ใน /workspace/conversations/ เติบโตตามทุกข้อความ ในการใช้งานหนักหลายเดือน ไฟล์แต่ละไฟล์อาจถึงระดับ megabyte ไม่มีการ rotate ในตัว หากคุณสนใจ ให้เพิ่ม cron-style job เพื่อ archive log ที่เก่ากว่า N วัน หรือแบ่งตามเดือน
สำหรับความกังวลด้านการ operate ที่กว้างขึ้นเกี่ยวกับ self-hosted service และสิ่งที่เกิดขึ้นเมื่อล้มเหลว ดูบทความของเราเรื่อง disaster recovery สำหรับ self-hosted service
สิ่งที่อยู่นอก Scope โดยเจตนา
สิ่งน่าล่อใจเมื่อสร้าง internal AI tool คือการเพิ่ม feature เราคง stack นี้ให้เล็ก สิ่งต่อไปนี้อยู่นอก scope อย่างชัดเจนสำหรับ version ที่อธิบายในที่นี้ และเราเลือกอย่างตั้งใจที่จะไม่เพิ่มในขณะนี้:
- RAG / vector database ความรู้ของ assistant อยู่ในไฟล์ Markdown (
CLAUDE.mdและnotes/) และ conversation log การเรียก Read-tool จัดการการ retrieve นี่เพียงพอสำหรับ scope single-mandate เมื่อ workspace ผ่านขนาดหนึ่ง RAG layer จริงๆ (PostgreSQL + pgvector เป็นต้น) จะจำเป็น แต่จนกว่านั้นมันคือ overkill - Audio transcription Voice note ถูกดาวน์โหลดและ metadata ถูก log ไว้ แต่ assistant ยังไม่สามารถถอดเสียงได้ การเพิ่ม Whisper หรือ pipeline ที่คล้ายกันใช้เวลาครึ่งวัน เลื่อนไปจนกว่าจะต้องการ
- Health check endpoint container ไม่มี HTTP server ไม่มีอะไรให้ scrape Docker's restart-policy บวกกับ log monitoring ครอบคลุม failure mode ส่วนใหญ่
- Streaming response ดูการตัดสินใจที่ 6 ข้างต้น
- Multi-tenant บน container เดียว แต่ละ mandate มี container ของตัวเอง นี่เป็นเจตนา ดูโมเดล project-instance ข้างต้น
Feature ที่เลื่อนออกไปไม่ใช่ bug พวกมันคือทางเลือก และพวกมันลด surface area ของสิ่งที่มีอยู่แล้ว สำหรับ context เรื่องวินัยการให้ AI tool ยังแคบอยู่ ดูที่ การสร้าง AI agent skills สำหรับ domain-specific workflow และ ภาพรวมของ Claude Skills
การ Clone สำหรับ Mandate ต่อไป
การออกแบบสอง env var จ่ายผลตอบแทนที่นี่ ในการตั้ง instance ใหม่:
- Clone repo ไปยัง
/opt/<new-instance-id> - เปลี่ยน
HERMES_PROJECT_NAMEและHERMES_INSTANCE_IDใน.env - ลงทะเบียน bot ใหม่กับ BotFather (ครั้งเดียว)
- กรอก workspace template พร้อม context ของ mandate ใหม่ (ครั้งเดียว)
- OAuth login จากภายใน container ใหม่ (ครั้งเดียว แนะนำให้ใช้ Anthropic account แยกต่างหาก)
docker compose up -d
โค้ดเหมือนกัน bit ต่อ bit ข้าม instance สิ่งที่แตกต่างคือเพียง env var สองตัว bot credentials เนื้อหา workspace และ OAuth session
คุณสามารถรันหลาย instance บน server เดียวกัน แต่ละตัวมี directory ของตัวเอง ชื่อ container ของตัวเอง bind-mount tree ของตัวเอง การใช้ disk คือประมาณ 700 MB image (shared ข้าม instance ด้วย Docker's layer cache) บวกกับ workspace growth ต่อ instance (โดยทั่วไปสิบๆ MB หลังจากหลายเดือน)
ที่ทางของสิ่งนี้ในชุด Tool
Project-specific AI assistant ไม่ใช่สิ่งทดแทน general-purpose AI coding tool เรายังใช้ Claude Code, Cursor และ Gemini CLI โดยตรงสำหรับงานพัฒนา Assistant นี้มีไว้สำหรับ consulting context: project memory, การวิเคราะห์เอกสาร, status update, ad-hoc research ภายใน mandate ที่กำหนด มันรันควบคู่กับ AI tool stack อื่นๆ ไม่ใช่แทนที่
รูปแบบนี้ไม่ใช่สิ่งทดแทน chat-based AI product เช่น Claude.ai หรือ ChatGPT ผลิตภัณฑ์เหล่านั้นเป็นคำตอบที่ถูกต้องสำหรับ task ครั้งเดียว คำถามส่วนตัว และงานความรู้ทั่วไป Project-specific assistant เป็นคำตอบที่ถูกต้องเมื่อโปรเจกต์มีขอบเขตของตัวเอง มีผู้เข้าร่วมของตัวเอง และมี knowledge base ที่พัฒนาไปเรื่อยๆ ของตัวเองที่คุณไม่ต้องการ dump ลงใน generic chatbot ทุกครั้งที่ต้องอ้างอิงถึง
หากคุณกำลังพิจารณาสร้างสิ่งที่คล้ายกันสำหรับ consulting practice, agency หรือทีมภายในของคุณ stack ข้างต้นเป็นจุดเริ่มต้นที่สมเหตุสมผล Dockerfile, compose file และโครงสร้าง module สามารถ reproduce ได้จากคู่มือนี้ การตัดสินใจมีการบันทึกไว้ หาก context ของคุณแตกต่างจากของเราในการตัดสินใจเจ็ดข้อใด ให้ branch และปรับ โค้ดสั้นพอที่การเขียน module ใหม่จะตรงไปตรงมา ติดต่อเรา หากต้องการแลกเปลี่ยนหรือให้เราช่วยในการ implement เฉพาะของคุณ