discli serve 會啟動常駐 bot 程序,透過 stdin/stdout 使用 JSONL(JSON Lines)進行通信。這是建置即時 Discord 代理的基礎。
為何使用 Serve 模式
標準 discli 指令是一次性(fire-and-forget):每筆指令都會連上 Discord、執行後斷線。這對腳本與單次作業沒問題,但對需要:
- 即時反應事件
- 逐字元串流回覆
- 顯示輸入中狀態
- 處理 slash command
- 不重連執行多個行為
serve 模式可維持一條 Discord WebSocket,並透過 stdin/stdout 的 JSONL 將連線暴露出去。協定目前支援 54 個動作與 17 種事件類型。
┌──────────────┐ stdin (JSONL) ┌──────────────────┐│ Your Agent │ ──────────────────▶ │ ││ (Python, │ │ discli serve │──── Discord API│ Node, etc) │ ◀────────────────── │ │└──────────────┘ stdout (JSONL) └──────────────────┘啟動 Serve 模式
discli serve旗標
| 旗標 | 說明 | 範例 |
|---|---|---|
--server | 只接收單一伺服器事件 | --server "My Server" |
--channel | 只接收單一頻道事件 | --channel "#general" |
--events | 以逗號分隔事件類型 | --events messages,reactions,members |
--include-self / --no-include-self | 是否接收機器人自己訊息(預設接收) | --no-include-self |
--slash-commands | JSON 檔定義 slash command | --slash-commands commands.json |
--status | 連線時機器人狀態 | --status idle |
--activity | 活動類型 | --activity watching |
--activity-text | 活動文字 | --activity-text "for commands" |
--profile | 權限設定檔 | --profile chat |
常見啟動組合
discli servediscli serve --server "My Server" --channel "#support" --events messagesdiscli serve --status online --activity watching --activity-text "for questions"discli serve --slash-commands commands.json --events messagesdiscli serve --profile chat --events messages,membersJSONL 協定
通訊資料為逐行 JSON。每行都是一個完整 JSON 物件,結尾加換行符。
事件(stdout)
serve 會將事件輸出到 stdout,代理逐行讀取。
{"event": "ready", "bot_id": "123456", "bot_name": "MyBot#1234"}{"event": "message", "channel_id": "789", "content": "hello", "author": "alice", "author_id": "456", "message_id": "101112", "mentions_bot": true, "is_dm": false, ...}{"event": "response", "req_id": "req-1", "ok": true, "message_id": "131415"}事件類型
ready
機器人連上 Discord Gateway 時只會送出一次。
{ "event": "ready", "bot_id": "123456789", "bot_name": "MyBot#1234"}message
對機器人可見的每則新訊息都會送出。
{ "event": "message", "server": "My Server", "server_id": "111222333", "channel": "general", "channel_id": "444555666", "author": "alice#1234", "author_id": "777888999", "is_bot": false, "content": "Hello bot!", "timestamp": "2025-03-15T10:30:00+00:00", "message_id": "101010101010", "mentions_bot": true, "is_dm": false, "attachments": [], "reply_to": null}message_edit
訊息被編輯時送出。
{ "event": "message_edit", "server": "My Server", "server_id": "111222333", "channel": "general", "channel_id": "444555666", "author": "alice#1234", "author_id": "777888999", "message_id": "101010101010", "old_content": "Hello", "new_content": "Hello bot!", "timestamp": "2025-03-15T10:31:00+00:00"}message_delete
訊息被刪除時送出。
{ "event": "message_delete", "server": "My Server", "server_id": "111222333", "channel": "general", "channel_id": "444555666", "author": "alice#1234", "author_id": "777888999", "message_id": "101010101010", "content": "Hello bot!"}reaction_add / reaction_remove
反應被新增或移除時送出。
{ "event": "reaction_add", "server": "My Server", "channel": "general", "channel_id": "444555666", "message_id": "101010101010", "emoji": "👍", "user": "alice#1234", "user_id": "777888999"}member_join / member_remove
成員加入或離開伺服器時送出。
{ "event": "member_join", "server": "My Server", "server_id": "111222333", "member": "bob#5678", "member_id": "999000111"}slash_command
使用者觸發已註冊 slash command 時送出。
{ "event": "slash_command", "command": "ask", "args": {"question": "What is discli?"}, "channel_id": "444555666", "user": "alice#1234", "user_id": "777888999", "guild_id": "111222333", "interaction_token": "abc-123-def", "is_admin": false}component_interaction
使用者點擊按鈕或在選單選擇時送出。
{ "event": "component_interaction", "custom_id": "approve_btn", "component_type": "button", "channel_id": "444555666", "message_id": "101010101010", "user": "alice#1234", "user_id": "777888999", "guild_id": "111222333", "interaction_token": "abc-123-def", "values": []}選單事件的 values 會包含使用者選取的選項值。
modal_submit
使用者提交 modal 表單時送出。
{ "event": "modal_submit", "custom_id": "feedback_modal", "channel_id": "444555666", "user": "alice#1234", "user_id": "777888999", "guild_id": "111222333", "interaction_token": "abc-123-def", "fields": { "title": "Bug Report", "description": "The button doesn't work" }}voice_state_update
成員加入、離開或切換語音頻道時送出。
{ "event": "voice_state_update", "server": "My Server", "server_id": "111222333", "member": "alice#1234", "member_id": "777888999", "channel_id": "444555666", "channel": "Voice Chat", "action": "join"}action 為 join、leave 或 move。
disconnect / resume
機器人斷線或重連時送出。
{"event": "disconnect", "code": 1001, "reason": "Going away"}{"event": "resume", "replayed": 5}response
代理寫入 stdin 後回傳的動作結果。會透過 req_id 對齊。
{ "event": "response", "req_id": "req-1", "ok": true, "message_id": "131415161718"}error
操作出錯時送出。
{ "event": "error", "message": "Channel not found: 999"}動作(stdin)
代理將 JSONL action 寫入 stdin。每個 action 都必須有 action 欄位。
{"action": "send", "channel_id": "444555666", "content": "Hello!", "req_id": "req-1"}req_id 可選,用來對齊回應;若有提供,回應事件會原封 return 相同值。
完整動作參考
| 動作 | 必要欄位 | 選用欄位 |
|---|---|---|
send | channel_id, content | embed, components, files, req_id |
reply | channel_id, message_id, content | embed, components, files, req_id |
edit | channel_id, message_id, content | components, req_id |
delete | channel_id, message_id | req_id |
bulk_delete | channel_id, message_ids | req_id |
dm_send | user_id, content | req_id |
{"action": "send", "channel_id": "444555666", "content": "Hello world!"}{"action": "reply", "channel_id": "444555666", "message_id": "101010", "content": "Got it!"}{"action": "edit", "channel_id": "444555666", "message_id": "131415", "content": "Updated text"}{"action": "delete", "channel_id": "444555666", "message_id": "131415"}{"action": "bulk_delete", "channel_id": "444555666", "message_ids": ["111", "222", "333"]}{"action": "dm_send", "user_id": "777888999", "content": "Hey there!"}Rich embeds: 在 send 或 reply 中加入 embed 物件即可。
{"action": "send", "channel_id": "444555666", "content": "", "embed": {"title": "Status", "description": "All systems go", "color": 5865F2, "footer": {"text": "Updated now"}, "fields": [{"name": "Uptime", "value": "99.9%", "inline": true}]}}訊息元件: 加入 components 陣列可附加按鈕與選單。
{"action": "send", "channel_id": "444555666", "content": "Pick an option:", "components": [{"type": "action_row", "components": [{"type": "button", "style": "primary", "label": "Approve", "custom_id": "approve_btn"}, {"type": "button", "style": "danger", "label": "Reject", "custom_id": "reject_btn"}]}]}{"action": "send", "channel_id": "444555666", "content": "Choose a category:", "components": [{"type": "action_row", "components": [{"type": "select", "custom_id": "category_select", "placeholder": "Select...", "options": [{"label": "Bug", "value": "bug"}, {"label": "Feature", "value": "feature"}]}]}]}| 動作 | 必要欄位 | 選用欄位 |
|---|---|---|
stream_start | channel_id | reply_to, interaction_token, req_id |
stream_chunk | stream_id, content | req_id |
stream_end | stream_id | req_id |
{"action": "stream_start", "channel_id": "444555666", "req_id": "s1"}{"action": "stream_chunk", "stream_id": "abc123", "content": "Hello "}{"action": "stream_chunk", "stream_id": "abc123", "content": "world!"}{"action": "stream_end", "stream_id": "abc123", "req_id": "s2"}| 動作 | 必要欄位 | 選用欄位 |
|---|---|---|
thread_create | channel_id, name | message_id, content, auto_archive_duration, req_id |
thread_send | thread_id, content | files, req_id |
thread_list | channel_id | req_id |
thread_archive | thread_id | req_id |
thread_unarchive | thread_id | req_id |
thread_rename | thread_id, name | req_id |
thread_add_member | thread_id, member_id | req_id |
thread_remove_member | thread_id, member_id | req_id |
{"action": "thread_create", "channel_id": "444555666", "name": "Discussion", "content": "Let's talk"}{"action": "thread_send", "thread_id": "999000111", "content": "Replying in thread"}{"action": "thread_list", "channel_id": "444555666"}{"action": "thread_archive", "thread_id": "999000111"}{"action": "thread_unarchive", "thread_id": "999000111"}{"action": "thread_rename", "thread_id": "999000111", "name": "Renamed Thread"}{"action": "thread_add_member", "thread_id": "999000111", "member_id": "777888999"}{"action": "thread_remove_member", "thread_id": "999000111", "member_id": "777888999"}| 動作 | 必要欄位 | 選用欄位 |
|---|---|---|
reaction_add | channel_id, message_id, emoji | req_id |
reaction_remove | channel_id, message_id, emoji | req_id |
reaction_users | channel_id, message_id, emoji | req_id |
typing_start | channel_id | req_id |
typing_stop | channel_id | req_id |
{"action": "typing_start", "channel_id": "444555666"}{"action": "reaction_add", "channel_id": "444555666", "message_id": "101010", "emoji": "👍"}{"action": "reaction_users", "channel_id": "444555666", "message_id": "101010", "emoji": "👍"}{"action": "typing_stop", "channel_id": "444555666"}| 動作 | 必要欄位 | 選用欄位 |
|---|---|---|
message_list | channel_id | limit, req_id |
message_get | channel_id, message_id | req_id |
message_search | channel_id | query, limit, author, req_id |
message_pin | channel_id, message_id | req_id |
message_unpin | channel_id, message_id | req_id |
channel_list | guild_id, req_id | |
channel_info | channel_id | req_id |
channel_create | guild_id, name | type, topic, req_id |
channel_edit | channel_id | topic, slowmode, name, req_id |
channel_set_permissions | channel_id, target | allow, deny, req_id |
channel_forum_post | channel_id, title, content | req_id |
server_list | req_id | |
server_info | guild_id | req_id |
member_list | guild_id | limit, req_id |
member_info | guild_id, member_id | req_id |
member_timeout | guild_id, member_id, duration | reason, req_id |
role_list | guild_id | req_id |
role_assign | guild_id, member_id, role_id | req_id |
role_remove | guild_id, member_id, role_id | req_id |
role_edit | guild_id, role_id | name, color, hoist, mentionable, req_id |
webhook_list | channel_id | req_id |
webhook_create | channel_id, name | req_id |
webhook_delete | webhook_id | req_id |
event_list | guild_id | req_id |
event_create | guild_id, name, start | end, description, req_id |
event_delete | guild_id, event_id | req_id |
| 動作 | 必要欄位 | 選用欄位 |
|---|---|---|
presence | status, activity_type, activity_text, req_id | |
interaction_followup | interaction_token, content | embed, components, req_id |
interaction_respond | interaction_token, content | ephemeral, req_id |
interaction_edit | interaction_token, content | components, req_id |
modal_send | interaction_token, custom_id, title, fields | req_id |
poll_send | channel_id, question, answers | duration_hours, multiple, content, req_id |
poll_results | channel_id, message_id | req_id |
poll_end | channel_id, message_id | req_id |
{"action": "presence", "status": "dnd", "activity_type": "watching", "activity_text": "for issues"}{"action": "interaction_followup", "interaction_token": "abc-123", "content": "Here's your answer!"}{"action": "interaction_respond", "interaction_token": "abc-123", "content": "Only you see this", "ephemeral": true}{"action": "interaction_edit", "interaction_token": "abc-123", "content": "Updated!", "components": []}{"action": "modal_send", "interaction_token": "abc-123", "custom_id": "feedback_modal", "title": "Feedback", "fields": [{"label": "Title", "custom_id": "title", "style": "short"}, {"label": "Details", "custom_id": "details", "style": "long"}]}{"action": "poll_send", "channel_id": "444555666", "question": "Best language?", "answers": ["Python", "Rust", "Go"]}{"action": "poll_results", "channel_id": "444555666", "message_id": "101010"}{"action": "poll_end", "channel_id": "444555666", "message_id": "101010"}請求/回應關聯
動作中加上 req_id 可對應回傳:
→ stdin: {"action": "send", "channel_id": "444555666", "content": "Hello!", "req_id": "msg-1"}← stdout: {"event": "response", "req_id": "msg-1", "ok": true, "message_id": "131415161718"}對 stream_start(會回傳 stream_id)或 thread_create(會回傳 thread_id)這類需結果的動作很重要。
req_id 可用遞增整數或 UUID。建議維護一個 map,將 req_id 映到 callback 或 queue,回傳到達時可正確分派。
Python 整合模式
同步模式(threading)
最簡化做法。用背景執行緒讀事件,主執行緒處理邏輯。
import jsonimport subprocessimport threadingfrom queue import Queue
class ServeClient: def __init__(self, **kwargs): cmd = ["discli", "serve"] for key, value in kwargs.items(): cmd.extend([f"--{key.replace('_', '-')}", str(value)])
self.proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) self.events = Queue() self.pending = {} self._counter = 0
threading.Thread(target=self._reader, daemon=True).start()
def _reader(self): for line in self.proc.stdout: data = json.loads(line.strip()) rid = data.get("req_id") if rid in self.pending: self.pending[rid].put(data) else: self.events.put(data)
def send(self, action, wait=False): self._counter += 1 rid = f"r{self._counter}" action["req_id"] = rid if wait: q = Queue() self.pending[rid] = q self.proc.stdin.write(json.dumps(action) + "\n") self.proc.stdin.flush() if wait: result = q.get(timeout=10) del self.pending[rid] return result
def next_event(self): return self.events.get()
# 使用方式client = ServeClient(events="messages")ready = client.next_event() # {"event": "ready", ...}
while True: event = client.next_event() if event["event"] == "message" and event.get("mentions_bot"): client.send({ "action": "reply", "channel_id": event["channel_id"], "message_id": event["message_id"], "content": "Hello!", })Asyncio
當代理需要並行作業(計時器、HTTP 呼叫等)時適用。
import asyncioimport json
class AsyncServeClient: def __init__(self): self.proc = None self.pending = {} self._counter = 0
async def start(self, **kwargs): cmd = ["discli", "serve"] for key, value in kwargs.items(): cmd.extend([f"--{key.replace('_', '-')}", str(value)])
self.proc = await asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, )
async def send(self, action, wait=False): self._counter += 1 rid = f"r{self._counter}" action["req_id"] = rid future = None if wait: future = asyncio.get_event_loop().create_future() self.pending[rid] = future self.proc.stdin.write((json.dumps(action) + "\n").encode()) await self.proc.stdin.drain() if wait: return await asyncio.wait_for(future, timeout=10)
async def read_events(self): while True: line = await self.proc.stdout.readline() if not line: break data = json.loads(line.decode().strip()) rid = data.get("req_id") if rid in self.pending: self.pending.pop(rid).set_result(data) else: yield data
async def main(): client = AsyncServeClient() await client.start(events="messages")
async for event in client.read_events(): if event["event"] == "ready": print(f"Ready as {event['bot_name']}") elif event["event"] == "message" and event.get("mentions_bot"): await client.send({ "action": "reply", "channel_id": event["channel_id"], "message_id": event["message_id"], "content": "Hello!", })
asyncio.run(main())Asyncio 在 Linux/macOS 可正常運作;Windows 上 asyncio.create_subprocess_exec 會使用 ProactorEventLoop,能正確處理子程序管道。
錯誤處理
動作錯誤會以 回應事件 回傳 error 欄位:
← {"event": "response", "req_id": "req-1", "error": "Channel not found: 999"}協定層錯誤(無效 JSON)會輸出 錯誤事件:
← {"event": "error", "message": "Invalid JSON: {bad json"}在使用回傳前務必檢查 error 欄位:
result = client.send({"action": "send", "channel_id": "999", "content": "test"}, wait=True)if "error" in result: print(f"Action failed: {result['error']}")else: print(f"Sent message: {result['message_id']}")Windows 的 stdin 提示
Windows 上,當程式被父進程透過 pipe 啟動時,asyncio 的 ProactorEventLoop 無法使用 connect_read_pipe 讀 stdin。discli 在 serve 層用背景執行緒讀取 stdin 並將每行放到 asyncio queue,外部不需要關心此實作細節。若你自行實作 stdin reader,請注意這個限制。
生命週期
啟動
以子程序方式啟動 discli serve,並建立 stdin/stdout pipes。
就緒
等待 stdout 上的 {"event": "ready"} 事件。此時 bot 已連上 Discord。
運作
從 stdout 讀事件、向 stdin 寫 action,並用 req_id 對齊回應。
關閉
關閉 stdin,serve 會關掉 Discord 連線並退出。你也可發送 SIGINT / Ctrl+C。