批量發送人與機器人會話中機器人訊息
第三方應用透過本介面,以指定內部應用機器人的身份,向一個或多個目標成員批量發送單聊訊息(文字訊息或結構化卡片訊息)。
服務端會先為每個目標成員批量建立或復用機器人單聊會話,再並發執行訊息投遞,並按目標使用者返回逐項結果。
介面資訊
| 項目 | 說明 |
|---|---|
| 介面名稱 | 批量發送人與機器人會話中機器人訊息 |
| 權限標識 | robot.push(機器人訊息推送) |
| 請求方式 | POST |
| 請求位址 | https://{gateway_host}/open/v1/robot/messages |
| 數據格式 | application/json |
| 鑑權方式 | 應用級 access_token(需綁定 robot.push 權限) |
| 冪等控制 | 請求頭 Idempotency-Key(必填) |
注意:本介面是第三方應用機器人通道,使用應用級
access_token鑑權。Webhook 自訂機器人是另一條獨立通道(使用加簽方案),兩者的鑑權憑證不能混用。
前置條件
- 應用狀態:應用在管理後台「內部應用」中已發布版本且處於啟用狀態。
- 機器人啟用:應用詳情「機器人」面板中已開啟機器人,且取得並記錄了
robotCode。 - 權限與憑證:已取得有效的應用級憑據
access_token,且該令牌具備robot.push權限。 - 使用者可見範圍:目標接收人(
userids)必須處於該應用的可見範圍內,超出可見範圍的使用者會被逐項攔截。
應用機器人接入步驟
應用機器人以應用身份向成員發送單聊訊息,使用應用 access_token 和 robotCode。適合告警、待辦和業務通知。
接入步驟
- 在應用設定中開啟機器人能力並發布應用。
- 取得服務端使用的
robotCode,不要將它下發到客戶端。 - 取得應用
access_token。 - 按本介面發送訊息;傳入多個目標使用者即為批量發送。
應用機器人支援 text 和 card 訊息;訊息內容和卡片欄位見訊息類型與卡片。
四語言最小範例
Shell
curl -sS -X POST "https://{gateway_host}/open/v1/robot/messages" \
-H "Authorization: Bearer access-token-xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: robot-demo-001" \
-d '{"robotCode":"robot_xxx","userids":["user-001"],"msgType":"text","content":{"text":"服務通知"}}'
PHP
<?php
// 繁體中文註解:機器人令牌和冪等鍵由服務端產生並儲存。
$ch = curl_init('https://{gateway_host}/open/v1/robot/messages');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer access-token-xxx', 'Content-Type: application/json', 'Idempotency-Key: robot-demo-001'], CURLOPT_POSTFIELDS => json_encode(['robotCode' => 'robot_xxx', 'userids' => ['user-001'], 'msgType' => 'text', 'content' => ['text' => '服務通知']], JSON_THROW_ON_ERROR), CURLOPT_RETURNTRANSFER => true]);
echo curl_exec($ch); curl_close($ch);
Golang
package main
import ("bytes"; "net/http")
func main() {
// 繁體中文註解:重試時保持 Idempotency-Key 和請求體不變。
body := []byte(`{"robotCode":"robot_xxx","userids":["user-001"],"msgType":"text","content":{"text":"服務通知"}}`)
request, _ := http.NewRequest(http.MethodPost, "https://{gateway_host}/open/v1/robot/messages", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer access-token-xxx"); request.Header.Set("Content-Type", "application/json"); request.Header.Set("Idempotency-Key", "robot-demo-001")
response, _ := http.DefaultClient.Do(request); defer response.Body.Close()
}
C++
#include <curl/curl.h>
int main() {
// 繁體中文註解:生產程式碼應讀取逐項投遞結果並記錄 request id。
CURL* handle = curl_easy_init();
const char* body = R"({"robotCode":"robot_xxx","userids":["user-001"],"msgType":"text","content":{"text":"服務通知"}})";
struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Authorization: Bearer access-token-xxx"); headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Idempotency-Key: robot-demo-001");
curl_easy_setopt(handle, CURLOPT_URL, "https://{gateway_host}/open/v1/robot/messages"); curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(handle, CURLOPT_POSTFIELDS, body);
const CURLcode result = curl_easy_perform(handle); curl_slist_free_all(headers); curl_easy_cleanup(handle); return result == CURLE_OK ? 0 : 1;
}
請求參數
請求頭
| 請求頭 | 必填 | 說明 |
|---|---|---|
Content-Type | 是 | 固定為 application/json |
Authorization | 二選一 | Bearer <access_token>,推薦的令牌攜帶方式 |
access_token(Query 參數) | 二選一 | 備選方式:在 URL Query 中攜帶,如 ?access_token=xxx |
X-Request-Id | 是 | 請求追蹤關聯 ID,必須全域唯一且非空白,響應體 requestId 中將原樣回顯 |
Idempotency-Key | 是 | 客戶端產生的冪等鍵,保證重試不產生重複訊息 |
約束規則:
- 令牌只能透過
Authorization請求頭或 Query 參數其一攜帶;若同時傳遞且值不一致將拒絕請求。 X-Secret內部共用密鑰由受信閘道自動注入,呼叫方嚴禁自行偽造。
請求體
{
"robotCode": "robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c",
"userids": ["10001", "10002"],
"msg": {
"mtype": "text",
"content": "您的驗證碼是 482913,5 分鐘內有效。"
}
}
| 參數 | 類型 | 必填 | 說明 |
|---|---|---|---|
robotCode | string | 是 | 應用機器人的唯一呼叫標識 |
userids | string[] | 是 | 接收訊息的使用者 ID 列表(正整數字串,不可重複,非空) |
msg | object | 是 | 訊息主體物件 |
msg.mtype | string | 是 | 訊息類型:text(文字)或 card(結構化卡片) |
msg.content | string | 條件必填 | text 類型時必填;card 類型時可選,卡片渲染與離線通知文案都取自 msg.ext |
msg.ext | object | 條件必填 | card 類型時必填,必須為 JSON 物件(傳 JSON 字串會被拒絕) |
欄位約束:
userids必須使用字串陣列傳輸,避免整數精度遺失。單次請求中不能出現重複 ID,且必須是正整數。
訊息類型與卡片結構
文字訊息(text)
mtype 為 text,正文透過 content 傳入:
{
"robotCode": "robot_xxx",
"userids": ["10001"],
"msg": {
"mtype": "text",
"content": "系統將在今晚 22:00 進行升級維護。"
}
}
結構化卡片訊息(card)
mtype 為 card,卡片結構透過 msg.ext 傳入,必須是 JSON 物件:
{
"robotCode": "robot_xxx",
"userids": ["10001", "10002"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "版本發布",
"content": "版本發布窗口臨近,請完成最後一輪檢查並同步相關同學。",
"img": "https://xxx.png",
"button": [
{
"type": "normal",
"text": "稍後處理",
"actionType": "url",
"action": "https://example.com/release-notes"
}
]
}
}
}
卡片欄位說明(msg.ext)
| 欄位 | 類型 | 必填 | 說明 |
|---|---|---|---|
title | string | 否 | 卡片大標題,最長 64 個字元 |
content | string | 否 | 卡片正文介绍,最長 2000 個字元 |
img | string | 否 | 卡片封面配圖 URL,非空時必須為合法的絕對 HTTP/HTTPS 位址 |
button | object[] | 否 | 操作按鈕陣列(最多支援 3 個按鈕) |
button[].type | string | 是 | 按鈕樣式,normal(普通按鈕)或 primary(主按鈕) |
button[].text | string | 是 | 按鈕文字 |
button[].actionType | string | 是 | 按鈕動作類型:url(跳轉連結)或 none(無操作/純展示) |
button[].action | string | 條件必填 | 按鈕跳轉 URL,actionType="url" 時必填 |
欄位約束:
msg.ext.title與msg.ext.content是卡片的文字描述,按 Unicode 字元計數,缺失或顯式為null視為未設定。兩個欄位展示位不同,各自使用獨立上限:title最長 64 個字元,content最長 2000 個字元。- 單個欄位超過各自上限時整批請求被拒絕,返回
card title is too long或card content is too long。 - 文字欄位傳入數字、物件、陣列等非字串取值時同樣整批拒絕,返回
invalid card title或invalid card content,避免繞過長度限制。
離線推送文案:
- 接收方離線時通知欄文案優先取
msg.ext.title,標題為空取msg.ext.content, 兩者都為空時使用備援文案你有一條新訊息(按 80 個字元截斷展示)。
訊息類型與卡片欄位詳解
說明應用機器人推送訊息的 mtype、content 與 ext 結構,以及客戶端展示規則。
本文只覆蓋開放介面(POST /open/v1/robot/messages)支援的訊息類型,
即 text 與 card 兩種。介面呼叫方式、鑑權與錯誤碼見《機器人檔案》。
訊息結構
開放介面中,訊息透過 msg 物件傳遞:
{
"mtype": "text",
"content": "訊息正文",
"ext": {}
}
| 欄位 | 類型 | 說明 |
|---|---|---|
mtype | string | 訊息類型,text 或 card |
content | string | 文字內容,mtype=text 時必填;mtype=card 時不使用 |
ext | object | 結構化擴展,mtype=card 時必填;mtype=text 時不使用 |
文字訊息
最簡形式,適合驗證碼、狀態提醒等純文字通知。
{
"mtype": "text",
"content": "您的驗證碼是 482913,5 分鐘內有效。"
}
| 約束 | 說明 |
|---|---|
content 必填 | 為空時整批請求返回 content is required |
| 長度 | 無強制上限,建議控制在客戶端可完整展示的範圍內 |
卡片訊息
卡片用於承載帶標題、正文、配圖和跳轉按鈕的結構化通知。
外層結構
mtype=card 時,卡片內容全部放在 ext 中,ext 必須是 JSON 物件:
{
"mtype": "card",
"content": "",
"ext": {
"title": "版本發布",
"content": "版本發布窗口臨近,請完成最後一輪檢查並同步相關同學。",
"img": "https://xxx.png",
"button": [
{ "type": "normal", "text": "稍後處理", "actionType": "url", "action": "https://example.com/release-notes" }
]
}
}
ext 欄位
| 欄位 | 類型 | 必填 | 說明 |
|---|---|---|---|
title | string | 否 | 卡片標題,為空則不顯示 |
content | string | 否 | 卡片正文,為空則不顯示 |
img | string | 否 | 卡片配圖位址,為空則不顯示 |
button | object[] | 否 | 卡片按鈕陣列,為空則不顯示 |
ext 中沒有任何必填業務欄位:只帶 title 的卡片、只帶按鈕的卡片都是合法的。
但 ext 本身必須存在,且必須是 JSON 物件——傳陣列或字串會返回 card ext is invalid。
文字長度限制
title 與 content 是卡片的文字描述,按 Unicode 字元計數,缺失或顯式為 null 視為未設定,
不參與校驗。兩個欄位在卡片上的展示位不同,各自使用獨立上限:
| 欄位 | 上限 | 超限時返回 |
|---|---|---|
title | 64 個字元 | card title is too long |
content | 2000 個字元 | card content is too long |
| 情況 | 結果 |
|---|---|
| 單個欄位不超過各自上限 | 通過 |
| 單個欄位超過各自上限 | 拒絕,返回 card title is too long 或 card content is too long |
| 欄位取值為數字、物件、陣列等非字串 | 拒絕,返回 invalid card title 或 invalid card content |
上限按單個欄位計算,title 與 content 不會合併計數。校驗失敗會導致整批請求被拒絕,
同一批次的所有使用者都不會收到訊息。
離線推送文案
接收方 App 離線或處於後台時,卡片訊息透過系統通知欄下發,展示文案按以下順序取值:
| 順序 | 取值 | 說明 |
|---|---|---|
| 1 | ext.title | 標題非空(去空白後)時優先作為通知文案 |
| 2 | ext.content | 標題為空時使用卡片正文 |
| 3 | 你有一條新訊息 | 標題與正文都為空時使用備援文案 |
通知文案按 80 個字元截斷,ext.img、ext.button 等結構化欄位不會進入通知欄。
同一會話在推送聚合窗口內累計多條訊息時,通知欄改展示「你有 N 條新訊息」,不再展示單條文字。
以上通知文案——包括備援文案、聚合條數模板,以及圖片、語音、視訊、檔案、位置、通話記錄等各訊息類型的
佔位文案——統一在 common/constant/message.go 的「離線推送通知欄文案」常數表中定義。
文案語言目前固定為簡體中文,多語言切換能力留待後續版本迭代。
按鈕欄位
| 欄位 | 類型 | 說明 |
|---|---|---|
type | string | 按鈕樣式,normal(普通按鈕)或 primary(主按鈕) |
text | string | 按鈕文案 |
actionType | string | 按鈕事件類型,目前僅支援 url |
action | string | 按鈕執行事件;actionType=url 時為打開的網頁位址 |
按鈕的展示與跳轉規則:
-
卡片按鈕的跳轉位址統一由客戶端內建瀏覽器打開。
-
第一行最多展示兩個按鈕,其餘按鈕每個獨占一行並撐滿寬度:
按鈕數 布局 1 個 該按鈕撐滿一整行 2 個 首行並排顯示兩個 3 個及以上 首行兩個,其餘每個獨占一行
位址校驗規則
卡片中所有跳轉位址必須攜帶 http:// 或 https:// 協定頭。
以下欄位會被校驗,值必須是絕對 HTTP/HTTPS URL:
| 欄位 | 校驗條件 |
|---|---|
ext.img | 只要非空就校驗 |
ext.button[].action | 僅當該按鈕的 actionType 為 url 時校驗 |
| 取值 | 結果 |
|---|---|
絕對 HTTP/HTTPS URL(如 https://example.com/a) | 通過 |
省略協定的位址(如 www.example.com) | 拒絕 |
javascript:、file:、data: 等自定義協議 | 拒絕 |
| 空字串 | 通過,該欄位視為未設定 |
校驗失敗會導致整批請求被拒絕,同一批次的所有使用者都不會收到訊息。 介面只返回統一的系統錯誤文案,不返回具體是哪個欄位、哪個值不合法, 排障時需自行檢查卡片內容。
按鈕的 action 僅在該按鈕的 actionType 為 url 時才會被校驗;actionType 缺失或取值不為
url 時,該位址不會被校驗,客戶端也不會按網頁跳轉處理。因此連結類按鈕請始終下發
actionType: "url",並攜帶完整協定頭。
展示規則
以下規則影響訊息的最終呈現效果,發送前建議確認卡片內容:
title、content、img、button任一為空時,對應區域不展示。- 卡片內容不會自動補全,也不會有兜底文案。
- 卡片解析異常時,可展示欄位為空。
會話列表摘要
機器人會話列表中的摘要按以下優先級取值:
ext.title 非空 → 使用 title
ext.title 為空 → 使用 ext.content
兩者都為空 → 使用統一卡片佔位文案
搜尋
使用者搜尋時,卡片的可搜尋文字僅包含 title 與 content,不包含圖片、按鈕文案與 URL。
通道差異
card 類型僅在應用機器人通道(POST /open/v1/robot/messages)可用。
群機器人 Webhook 通道(POST /webhook/robot/send)支援的類型為
text、image、voice、video、file、location、custom,不包含 card。
冪等與重試機制
| 項目 | 說明 |
|---|---|
| 冪等標識 | 請求頭 Idempotency-Key,呼叫方必填 |
| 作用範圍 | 應用維度唯一,有效期為 24 小時 |
| 語義校驗 | 服務端記錄請求體摘要指紋 |
| 相同報文重複提交 | 判定為網路逾時或重複重試,直接返回上次處理結果,不產生重複訊息 |
| 不同報文復用同一 Key | 服務端拒絕請求,返回錯誤 IDEMPOTENCY_KEY_REUSED |
重試建議:在網路逾時或未獲得明確響應時,必須保持完全相同的 Idempotency-Key 與請求體進行重試。
響應參數
成功響應(HTTP 200,平台統一響應外殼):
{
"code": 200,
"msg": "",
"data": {
"requestId": "req-1710000000000000000",
"results": [
{
"index": 0,
"userId": "10001",
"status": "queued",
"sessionId": "30001",
"messageId": "1234567890123456789",
"seq": "102"
},
{
"index": 1,
"userId": "10002",
"status": "rejected",
"errorCode": "USER_NOT_ALLOWED",
"errorMessage": "user is not allowed"
}
],
"summary": {
"total": 2,
"success": 1,
"failed": 1
}
}
}
響應欄位說明
| 欄位 | 類型 | 說明 |
|---|---|---|
code | integer | 業務狀態碼,200 表示請求已成功接收處理 |
msg | string | 錯誤資訊,成功時為空 |
data.requestId | string | 對應請求頭 X-Request-Id |
data.results | object[] | 目標使用者逐條投遞結果,順序與請求 userids 一致 |
data.results[].index | integer | 對應請求中 userids 的下標(從 0 開始) |
data.results[].userId | string | 目標使用者 ID |
data.results[].status | string | 投遞狀態:queued(已排隊入庫)、duplicate(冪等去重命中)、rejected(權限攔截)、failed(失敗) |
data.results[].sessionId | string | 該使用者與機器人的單聊會話 ID(成功時返回) |
data.results[].messageId | string | 訊息唯一 ID(MID,成功時返回) |
data.results[].seq | string | 該會話內嚴格單調遞增的序列號(成功時返回) |
data.results[].errorCode | string | 失敗或攔截時的錯誤碼(僅失敗時返回) |
data.results[].errorMessage | string | 錯誤詳情描述(僅失敗時返回) |
data.summary | object | 匯總統計 |
data.summary.total | integer | 目標使用者總數 |
data.summary.success | integer | 成功進入下發佇列數(queued + duplicate 計入成功) |
data.summary.failed | integer | 失敗/攔截總數(rejected + failed 計入失敗) |
投遞狀態(status)取值
queued:訊息已被服務端接受並成功寫入訊息佇列,即將下發給客戶端。duplicate:命中冪等快取,此前已成功投遞。rejected:目標使用者未通過權限校驗(如超出可見範圍、使用者不存在或被禁用)。failed:服務端會話建立或訊息投遞執行失敗。
錯誤碼說明
鑑權與傳輸錯誤(HTTP 狀態碼)
| HTTP 狀態碼 | msg 錯誤碼 | 說明與處理建議 |
|---|---|---|
| 400 | INVALID_REQUEST | Content-Type 不為 application/json,或缺少 X-Request-Id |
| 401 | INVALID_TOKEN | 令牌缺失、已過期、失效,或應用已被停用/刪除 |
| 403 | SCOPE_DENIED | 令牌缺少 robot.push 權限 |
| 403 | IP_DENIED | 請求來源 IP 未命中應用安全設定的 IP 白名單 |
| 503 | AUTH_UNAVAILABLE | 鑑權依賴暫時不可用 |
請求級業務錯誤(HTTP 200,code != 200)
msg 錯誤碼 | 說明 |
|---|---|
missing idempotency key | 請求頭缺少 Idempotency-Key |
invalid request | 請求參數缺失,robotCode、userids 或 msg.mtype 為空 |
content is required | 文字訊息類型的 content 為空 |
card ext is invalid | 卡片訊息的 ext 缺失、不是合法 JSON 物件,或傳入了字串 |
card title is too long | 卡片 ext.title 超過 64 個字元 |
card content is too long | 卡片 ext.content 超過 2000 個字元 |
invalid card title | 卡片 ext.title 不是字串(如傳入了數字、物件或陣列) |
invalid card content | 卡片 ext.content 不是字串(如傳入了數字、物件或陣列) |
unsupported message type | mtype 不是 text 或 card |
robot unavailable | 機器人或應用未啟用、未發布,或機器人帳號異常 |
invalid user id at index N | 第 N 個使用者 ID 格式不是合法的正整數字串 |
duplicate user id | userids 列表中包含重複的使用者 ID |
IDEMPOTENCY_KEY_REUSED | 同一個 Idempotency-Key 綁定了不同的請求體 |
IDEMPOTENCY_UNAVAILABLE | 冪等儲存不可用,請稍後重試 |
單項使用者錯誤碼(results[].errorCode)
errorCode | 對應 status | 說明 |
|---|---|---|
USER_NOT_ALLOWED | rejected | 目標使用者不在目前應用的可見範圍內,或使用者不存在 |
USER_UNAVAILABLE | rejected / failed | 使用者狀態不可用(已被禁用或刪除) |
SESSION_FAILED | failed | 建立或查詢機器人單聊會話失敗 |
SESSION_TYPE_INVALID | failed | 會話服務返回的會話類型不是機器人會話 |
MESSAGE_FAILED | failed | 訊息投遞 MQ 失敗 |
呼叫範例
Shell (cURL) 完整範例
#!/usr/bin/env bash
set -euo pipefail
# 基礎設定
GATEWAY_HOST="https://im-gateway.example.com"
APP_ID="app_10001"
APP_SECRET="link_sec_xxxxxxxxxxxxxxxxxxxx"
ROBOT_CODE="robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c"
# 取得應用級 access_token
echo "==> 正在取得 access_token..."
TOKEN_RESP=$(curl -s -X POST "${GATEWAY_HOST}/auth/v1/oauth/token" \
-H "Content-Type: application/json" \
-d "{
\"client_id\": \"${APP_ID}\",
\"client_secret\": \"${APP_SECRET}\",
\"grant_type\": \"client_credentials\"
}")
ACCESS_TOKEN=$(echo "${TOKEN_RESP}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -z "${ACCESS_TOKEN}" ]; then
echo "取得 access_token 失敗: ${TOKEN_RESP}"
exit 1
fi
echo "==> 取得成功: ${ACCESS_TOKEN:0:10}..."
# 建構請求頭與訊息內容
REQUEST_ID="req-$(date +%s%N)"
IDEMPOTENCY_KEY="idem-$(date +%s)-${RANDOM}"
# 範例:發送結構化卡片訊息
REQ_BODY=$(cat <<EOF
{
"robotCode": "${ROBOT_CODE}",
"userids": ["10001", "10002"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "版本發布",
"content": "版本發布窗口臨近,請完成最後一輪檢查並同步相關同學。",
"img": "https://xxx.png",
"button": [
{
"type": "normal",
"text": "稍後處理",
"actionType": "url",
"action": "https://example.com/release-notes"
}
]
}
}
}
EOF
)
# 發送機器人訊息
echo "==> 正在發送機器人訊息..."
RESP=$(curl -s -X POST "${GATEWAY_HOST}/open/v1/robot/messages" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "X-Request-Id: ${REQUEST_ID}" \
-H "Idempotency-Key: ${IDEMPOTENCY_KEY}" \
-d "${REQ_BODY}")
echo "==> 介面返回結果:"
echo "${RESP}"
Golang 完整範例
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// 設定參數
const (
GatewayHost = "https://im-gateway.example.com"
AppID = "app_10001"
AppSecret = "link_sec_xxxxxxxxxxxxxxxxxxxx"
RobotCode = "robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c"
)
// OAuthTokenResponse 取得 Token 的響應結構
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error,omitempty"`
}
// CardButton 卡片按鈕
type CardButton struct {
Type string `json:"type"` // normal 或 primary
Text string `json:"text"`
ActionType string `json:"actionType"` // url 或 none
Action string `json:"action,omitempty"`
}
// CardExt 卡片內容結構
type CardExt struct {
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Img string `json:"img,omitempty"`
Button []CardButton `json:"button,omitempty"`
}
// RobotMessage 訊息結構
type RobotMessage struct {
MType string `json:"mtype"` // text 或 card
Content string `json:"content,omitempty"` // 文字正文或卡片摘要
Ext *CardExt `json:"ext,omitempty"` // 卡片擴展物件
}
// SendMessageRequest 發送訊息請求體
type SendMessageRequest struct {
RobotCode string `json:"robotCode"`
UserIDs []string `json:"userids"`
Msg RobotMessage `json:"msg"`
}
// UserResult 單個使用者的投遞結果
type UserResult struct {
Index int `json:"index"`
UserID string `json:"userId"`
Status string `json:"status"` // queued, duplicate, rejected, failed
SessionID string `json:"sessionId,omitempty"`
MessageID string `json:"messageId,omitempty"`
Seq string `json:"seq,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// Summary 批量彙總
type Summary struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
}
// SendMessageResponse 發送訊息響應
type SendMessageResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
RequestID string `json:"requestId"`
Results []UserResult `json:"results"`
Summary Summary `json:"summary"`
} `json:"data"`
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
// FetchAccessToken 取得應用級 access_token
func FetchAccessToken(ctx context.Context, host, appID, appSecret string) (string, error) {
reqBody, _ := json.Marshal(map[string]string{
"client_id": appID,
"client_secret": appSecret,
"grant_type": "client_credentials",
})
url := fmt.Sprintf("%s/auth/v1/oauth/token", host)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("token 請求失敗: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("取得 token 錯誤 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}
var tokenResp OAuthTokenResponse
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
return "", fmt.Errorf("解析 token 響應失敗: %w", err)
}
return tokenResp.AccessToken, nil
}
// SendRobotMessage 呼叫發送機器人訊息介面
func SendRobotMessage(ctx context.Context, host, token, reqID, idemKey string, payload SendMessageRequest) (*SendMessageResponse, error) {
bodyData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化請求體失敗: %w", err)
}
url := fmt.Sprintf("%s/open/v1/robot/messages", host)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", reqID)
req.Header.Set("Idempotency-Key", idemKey)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("發送訊息 HTTP 請求失敗: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("讀取響應失敗: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("服務端返回異常 (HTTP %d): %s", resp.StatusCode, string(respBytes))
}
var result SendMessageResponse
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("解析業務響應失敗: %w", err)
}
return &result, nil
}
func main() {
ctx := context.Background()
// 1. 取得 access_token
token, err := FetchAccessToken(ctx, GatewayHost, AppID, AppSecret)
if err != nil {
fmt.Printf("取得 Token 失敗: %v\n", err)
return
}
fmt.Println("成功取得 access_token")
// 2. 建構訊息內容(以結構化卡片為例)
reqPayload := SendMessageRequest{
RobotCode: RobotCode,
UserIDs: []string{"10001", "10002"},
Msg: RobotMessage{
MType: "card",
Content: "",
Ext: &CardExt{
Title: "版本發布",
Content: "版本發布窗口臨近,請完成最後一輪檢查並同步相關同學。",
Img: "https://xxx.png",
Button: []CardButton{
{
Type: "normal",
Text: "稍後處理",
ActionType: "url",
Action: "https://example.com/release-notes",
},
},
},
},
}
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
idempotencyKey := fmt.Sprintf("idem-%d", time.Now().UnixNano())
// 3. 執行單聊機器人訊息推送
resp, err := SendRobotMessage(ctx, GatewayHost, token, requestID, idempotencyKey, reqPayload)
if err != nil {
fmt.Printf("推送訊息失敗: %v\n", err)
return
}
// 4. 列印投遞結果
fmt.Printf("訊息發送完成! 狀態碼: %d, 彙總: 總數 %d, 成功 %d, 失敗 %d\n",
resp.Code, resp.Data.Summary.Total, resp.Data.Summary.Success, resp.Data.Summary.Failed)
for _, item := range resp.Data.Results {
fmt.Printf(" - 使用者 %s: 狀態=%s, 會話ID=%s, 訊息ID=%s, 錯誤碼=%s\n",
item.UserID, item.Status, item.SessionID, item.MessageID, item.ErrorCode)
}
}
PHP
<?php
// 繁體中文註解:卡片欄位保持為巢狀物件,按鈕動作使用純 HTTP(S) URL。
$payload = [
'robotCode' => 'robot_xxx',
'userids' => ['10001'],
'msg' => [
'mtype' => 'card',
'content' => '',
'ext' => [
'title' => '版本發布',
'content' => '版本發布窗口臨近,請完成最後一輪檢查並同步相關同學。',
'img' => 'https://xxx.png',
'button' => [[
'type' => 'normal',
'text' => '稍後處理',
'actionType' => 'url',
'action' => 'https://example.com/release-notes',
]],
],
],
];
$handle = curl_init('https://{gateway_host}/open/v1/robot/messages');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer access-token-xxx',
'Content-Type: application/json',
'X-Request-Id: req-20260920-0002',
'Idempotency-Key: idem-20260920-0002',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($handle);
curl_close($handle);
C++
#include <curl/curl.h>
int main() {
// 繁體中文註解:action 欄位必須是純 URL,不能把 Markdown 連結語法作為請求值。
const char* payload = R"({
"robotCode": "robot_xxx",
"userids": ["10001"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "版本發布",
"content": "版本發布窗口臨近,請完成最後一輪檢查並同步相關同學。",
"img": "https://xxx.png",
"button": [{
"type": "normal",
"text": "稍後處理",
"actionType": "url",
"action": "https://example.com/release-notes"
}]
}
}
})";
CURL* handle = curl_easy_init();
struct curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Authorization: Bearer access-token-xxx");
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, "X-Request-Id: req-20260920-0002");
headers = curl_slist_append(headers, "Idempotency-Key: idem-20260920-0002");
curl_easy_setopt(handle, CURLOPT_URL, "https://{gateway_host}/open/v1/robot/messages");
curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(handle, CURLOPT_POSTFIELDS, payload);
const CURLcode result = curl_easy_perform(handle);
curl_slist_free_all(headers);
curl_easy_cleanup(handle);
return result == CURLE_OK ? 0 : 1;
}
會話與展示規則
機器人訊息進入客戶端後以機器人會話展示。應用後端只負責發送訊息,不應假設客戶端會話已存在或會立即打開。
展示要點
- 應用機器人以應用身份展示,群聊機器人以群內機器人身份展示。
- 會話列表摘要由客戶端根據訊息類型產生,撤回、卡片和附件可能使用不同的摘要文案。
- 機器人訊息仍受目標使用者、應用狀態和可見範圍約束。
- 應用後端應使用服務端返回的訊息標識和逐項投遞狀態做審計,不要以客戶端展示作為成功依據。
相關介面
批量發送(多個目標使用者)
本介面同時承擔單聊與批量發送:userids 傳入多個目標使用者時,服務端按使用者逐項投遞並返回逐項結果。除 userids 擴展為目標使用者列表外,請求頭、冪等鍵和其他欄位與單聊發送完全一致。
{ "robotCode": "robot_xxx", "userids": ["user-001", "user-002"], "msg": { "mtype": "text", "content": "批量通知" } }
code != 200表示整批請求失敗,不能按部分成功處理。results[]是逐項結果,必須按使用者逐個處理:rejected通常是權限或可見範圍問題,failed才按錯誤碼決定是否重試。- 批量請求應按業務規模分批,避免瞬時觸達過多成員。
- 網路逾時使用同一
Idempotency-Key與請求體重試,避免重複發送。