WebSocket 实时接口

连接到 wss://cliqer.io/ws,即可像使用演示遥控器一样,通过您自己的软件或演出控制系统操控 Cliqer 房间:切换幻灯片、运行主机的计时器、用激光点指示,以及向主机发送消息。您的应用以演讲者身份加入房间;房间本身始终由 Cliqer 桌面应用托管。
session_token cookie(浏览器会自动发送)、?token= 查询参数或 Authorization: Bearer 请求头。令牌在连接时校验,而不是每条命令都校验,因此令牌过期后,请使用新令牌建立新连接并重新加入。没有令牌也可以连接,但无法使用 API。团队 API 密钥(cliqer_...)仅用于 REST 端点的身份验证;在此套接字上,API 密钥不是会话令牌。连接
# WebSocket connections require a WebSocket client
# Using websocat for testing:
websocat "$WS_URL/ws?token=YOUR_ACCESS_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
ws.onopen = () => {
console.log('Connected to Cliqer WebSocket')
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
console.log('Received:', message)
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
import websocket
import json
ws = websocket.WebSocketApp(
f"$WS_URL/ws?token={access_token}",
on_message=lambda ws, msg: print(json.loads(msg)),
on_error=lambda ws, err: print(f"Error: {err}"),
on_open=lambda ws: print("Connected")
)
ws.run_forever()
import (
"github.com/gorilla/websocket"
)
url := "$WS_URL/ws?token=" + accessToken
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
fmt.Printf("Received: %s\n", message)
}
use WebSocket\Client;
$client = new Client("$WS_URL/ws?token={$accessToken}");
while (true) {
$message = $client->receive();
$data = json_decode($message, true);
print_r($data);
}
use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::{StreamExt, SinkExt};
let url = format!("$WS_URL/ws?token={}", access_token);
let (mut ws_stream, _) = connect_async(&url).await?;
while let Some(msg) = ws_stream.next().await {
match msg? {
Message::Text(text) => {
let data: serde_json::Value = serde_json::from_str(&text)?;
println!("Received: {:?}", data);
}
_ => {}
}
}
服务器完成连接设置后,会发送带有您的 clientId 的 connected 事件。请在收到该事件后再发送任何内容:在此之前到达的消息会被拒绝并返回 error,同时连接将被关闭。
连接与房间流程
幻灯片导航
像使用演示遥控器一样,通过您自己的软件控制房间中的幻灯片。您的应用以演讲者身份加入房间,因此主机会在演讲者列表中以其 presenterName 显示它,并像对待任何演讲者一样控制它:操作员可以关闭它的点击权限、将其移除或屏蔽。
- 使用会话令牌连接(
?token=、Authorization: Bearer请求头或session_tokencookie)。团队 API 密钥仅适用于 REST 端点,不适用于此套接字。 - 房间所有者的套餐必须包含 Client API:Presenter、Studio HD 和 Studio 4K 套餐需添加 API 附加组件,Studio 8K 和 Enterprise 套餐已包含。
- 仅当主机允许来自您连接的点击时,幻灯片和计时器命令才会执行。
加入房间
{ "type": "joinRoom", "payload": { "room": "ABC123", "presenterName": "Show control", "api": true } }
- 请在收到
connected事件后发送:服务器会拒绝在此之前到达的任何内容。 presenterName是主机看到的名称(默认为API)。您的应用在房间中始终是演讲者,绝不会成为主机。- 发送命令前,请等待载荷为
true的clicksEnabled事件。主机完成您连接的设置后会发送该事件,此后每当操作员开启或关闭您的点击权限时也会再次发送。 - 保存
joinedRoom中的presenterKey,并在重新连接时将其放入joinRoom发送,这样主机会保留您的点击权限。 - 每 15 秒发送一次文本帧
ping以保持连接。服务器会以文本帧pong回应(不是 JSON)。
发送命令
{ "type": "webAction", "payload": { "action": "goto", "slide": 5, "id": "a3" } }
action | 附加字段 | 作用 |
|---|---|---|
next | 下一张幻灯片(或动画步骤) | |
prev | 上一张幻灯片(或动画步骤) | |
first | 第一张幻灯片 | |
last | 最后一张幻灯片 | |
goto | slide:1 到 9999 之间的整数 | 跳转到该幻灯片 |
blackout | on:true 或 false | 黑屏,或恢复显示 |
id为可选项:由 1 到 64 个字母、数字、_、.、:或-组成。它会在结果中返回。每条命令请使用新的id:如果主机在过去 5 秒内已从您的连接收到过相同id的命令,会将其丢弃,因此同一命令的重试只会执行一次。first、last、goto和blackout仅在最新controls事件的nav中列出了它们的主机上执行。未发送controls事件的主机仅支持next和prev。房间中有多个主机时,每个主机执行其支持的命令。- 屏幕处于黑屏状态时,
next或prev会恢复显示而不翻页。 - 超出最后一张幻灯片的
goto会被忽略。 - 每秒最多发送 10 条命令,幻灯片命令和计时器命令合并计算。主机还会丢弃 50 毫秒内重复的相同命令。
各演示应用目前支持的命令:
| 演示应用 | next、prev | first、last | goto | blackout |
|---|---|---|---|---|
| Windows 版 PowerPoint | 是 | 是 | 是 | 是 |
| Mac 版 PowerPoint | 是 | 是 | 否 | 否 |
| Keynote | 是 | 否 | 否 | 是 |
| Google Slides(已连接浏览器扩展) | 是 | 是 | 否 | 否 |
| vMix | 是 | 否 | 否 | 否 |
| 当前焦点窗口 | 是 | 否 | 否 | 否 |
controls 事件会实时反映您所控制主机的支持情况:请以其中的 nav 为准,而不是本表。
# One connection: wait for `connected`, join as an API client, give the host a
# moment to set it up, then send commands. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"webAction","payload":{"action":"next","id":"a1"}}'
sleep 1
echo '{"type":"webAction","payload":{"action":"prev","id":"a2"}}'
sleep 1 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
const send = (message: object) => ws.send(JSON.stringify(message))
let ready = false
ws.onmessage = (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
send({ type: 'joinRoom', payload: { room: 'ABC123', presenterName: 'Show control', api: true } })
setInterval(() => ws.send('ping'), 15000) // keeps the connection open
}
if (msg.type === 'clicksEnabled' && msg.payload === true && !ready) {
ready = true
send({ type: 'webAction', payload: { action: 'next', id: 'a1' } })
// Also: { action: 'prev' }, { action: 'first' }, { action: 'last' },
// { action: 'goto', slide: 5 }, { action: 'blackout', on: true }
}
if (msg.type === 'controls') console.log('Commands this host supports:', ['next', 'prev', ...msg.payload.nav])
if (msg.type === 'webActionResult') console.log(`${msg.payload.action} delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'slide') console.log('Slide:', msg.payload?.number ?? 'no deck open')
if (msg.type === 'error') console.error(msg.error, msg.payload?.message)
}
import json
import threading
import time
import websocket
ready = False
def send(ws, message):
ws.send(json.dumps(message))
def keepalive(ws): # keeps the connection open
while ws.sock and ws.sock.connected:
time.sleep(15)
ws.send("ping")
def on_message(ws, message):
global ready
if message == "pong":
return
msg = json.loads(message)
if msg["type"] == "connected":
send(ws, {"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": True}})
threading.Thread(target=keepalive, args=(ws,), daemon=True).start()
elif msg["type"] == "clicksEnabled" and msg["payload"] is True and not ready:
ready = True
send(ws, {"type": "webAction", "payload": {"action": "next", "id": "a1"}})
# Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": True}, ...
elif msg["type"] == "webActionResult":
print(f"{msg['payload']['action']} delivered to {msg['payload']['hosts']} host(s)")
elif msg["type"] == "slide":
print("Slide:", msg["payload"]["number"] if msg["payload"] else "no deck open")
elif msg["type"] == "error":
print("Error:", msg["error"], msg["payload"]["message"])
ws = websocket.WebSocketApp(f"$WS_URL/ws?token={access_token}", on_message=on_message)
ws.run_forever()
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
conn, _, err := websocket.DefaultDialer.Dial("$WS_URL/ws?token="+accessToken, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
var writeMu sync.Mutex // one writer at a time
send := func(message interface{}) {
writeMu.Lock()
defer writeMu.Unlock()
conn.WriteJSON(message)
}
go func() { // keeps the connection open
for range time.Tick(15 * time.Second) {
writeMu.Lock()
conn.WriteMessage(websocket.TextMessage, []byte("ping"))
writeMu.Unlock()
}
}()
ready := false
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
if string(data) == "pong" {
continue
}
var msg struct {
Type string `json:"type"`
Error string `json:"error"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(data, &msg) != nil {
continue
}
switch msg.Type {
case "connected":
send(map[string]interface{}{
"type": "joinRoom",
"payload": map[string]interface{}{"room": "ABC123", "presenterName": "Show control", "api": true},
})
case "clicksEnabled":
if string(msg.Payload) == "true" && !ready {
ready = true
send(map[string]interface{}{
"type": "webAction",
"payload": map[string]interface{}{"action": "next", "id": "a1"},
})
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
case "webActionResult", "slide", "error":
fmt.Printf("%s %s %s\n", msg.Type, msg.Error, msg.Payload)
}
}
use WebSocket\Client;
use WebSocket\TimeoutException;
// A 15 second read timeout doubles as the keepalive timer.
$client = new Client("$WS_URL/ws?token={$accessToken}", ['timeout' => 15]);
$ready = false;
while (true) {
try {
$message = $client->receive();
} catch (TimeoutException $e) {
$client->text('ping'); // keeps the connection open
continue;
}
if ($message === null) break; // closed
if ($message === 'pong') continue;
$msg = json_decode($message, true);
if ($msg['type'] === 'connected') {
$client->text(json_encode([
'type' => 'joinRoom',
'payload' => ['room' => 'ABC123', 'presenterName' => 'Show control', 'api' => true]
]));
} elseif ($msg['type'] === 'clicksEnabled' && $msg['payload'] === true && !$ready) {
$ready = true;
$client->text(json_encode([
'type' => 'webAction',
'payload' => ['action' => 'next', 'id' => 'a1']
]));
// Also: ['action' => 'goto', 'slide' => 5], ['action' => 'blackout', 'on' => true], ...
} elseif (in_array($msg['type'], ['webActionResult', 'slide', 'error'], true)) {
echo json_encode($msg), PHP_EOL;
}
}
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio_tungstenite::{connect_async, tungstenite::Message};
let url = format!("$WS_URL/ws?token={}", access_token);
let (ws_stream, _) = connect_async(&url).await?;
let (mut write, mut read) = ws_stream.split();
let every = std::time::Duration::from_secs(15);
let mut keepalive = tokio::time::interval_at(tokio::time::Instant::now() + every, every);
let mut ready = false;
loop {
tokio::select! {
_ = keepalive.tick() => write.send(Message::Text("ping".into())).await?, // keeps the connection open
frame = read.next() => {
let Some(frame) = frame else { break };
let Message::Text(text) = frame? else { continue };
if text.as_str() == "pong" { continue }
let msg: Value = serde_json::from_str(&text)?;
match msg["type"].as_str() {
Some("connected") => {
let join = json!({"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": true}});
write.send(Message::Text(join.to_string().into())).await?;
}
Some("clicksEnabled") if msg["payload"] == true && !ready => {
ready = true;
let next = json!({"type": "webAction", "payload": {"action": "next", "id": "a1"}});
write.send(Message::Text(next.to_string().into())).await?;
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
Some("webActionResult") | Some("slide") | Some("error") => println!("{}", msg),
_ => {}
}
}
}
}
结果与事件
每条命令都会收到 webActionResult 或 error 作为回应。
{ "type": "webActionResult", "payload": { "action": "next", "id": "a1", "hosts": 1 } }
hosts 是命令被送达的主机数量。命令已执行的证据是主机的下一个 slide 事件;blackout 还会改变主机下一个 controls 事件中的 blackout。主机未执行的命令(点击权限已关闭、不支持的命令、超出最后一张幻灯片的 goto)会收到结果,但不会有 slide 事件。
error | 触发情况 |
|---|---|
auth_required | 在没有会话令牌的连接上发送带 api: true 的 joinRoom |
not_api_client | 来自未以 api: true 加入房间或已离开房间的连接的命令 |
invalid_web_action | 未知的 action,或 slide、on、id 缺失或无效 |
invalid_timer_action | 未知的计时器 action,或 minutes、id 缺失或无效 |
invalid_message | 消息缺少 text,或超过 1000 个字符 |
invalid_presenter_mouse | 指针位置超出 0 到 1 的范围 |
rate_limited | 一秒内超过 10 条幻灯片和计时器命令,或 10 秒内超过 5 条消息 |
no_host | 房间中没有已连接的主机 |
host_update_required | 房间中的所有主机都运行着无法接收 API 命令的旧版 Cliqer |
api_not_in_plan | 房间所有者的套餐不包含 Client API |
错误包含可读的 message;如果命令格式正确,还会包含该命令的 action 和 id:
{ "type": "error", "error": "invalid_web_action", "payload": { "message": "Unknown or incomplete webAction. Use next, prev, first, last, goto with slide, or blackout with on.", "action": "goto", "id": "a3" } }
来自主机的事件会在 hostId 中携带该主机的 ID:
| 事件 | 载荷 |
|---|---|
clicksEnabled | 主机接收您的命令时为 true,操作员关闭您的点击权限时为 false |
controls | { app, nav, blackout, timer }:演示应用、它支持的额外幻灯片命令(first、last、goto、blackout)、屏幕是否为黑屏,以及主机是否接收计时器命令 |
slide | 主机每执行一条命令后发送 { number, current, total, title, notes }(number 形如 "4/12",应用不报告总数时 total 为 null),主机未打开幻灯片时为 null |
countdown | 计时器运行期间每秒发送主机计时器显示的时间,例如 "4:59"(超时时为 "-0:12"),计时器停止时为 null |
countdownPhase | { phase, blink }:phase 为 idle、talk、warning(剩余不到一分钟)、overtime 或 paused,blink 表示主机的计时器是否在闪烁 |
messageFromHost | 主机操作员的回复,参见向主机发送消息 |
您还会收到房间本身的事件(clientJoined、clientLeft 等);忽略不需要的事件即可。如果操作员移除或屏蔽您的连接,您会收到带有 reason: "kicked" 的 logout。
计时器
使用与 Stream Deck 或 Bitfocus Companion 发送的相同命令,运行主机 Cliqer 应用中的计时器。
{ "type": "timerAction", "payload": { "action": "set", "minutes": 25, "id": "t1" } }
action | 作用 |
|---|---|
start | 启动计时器,或在 stop 后继续计时 |
stop | 暂停计时器 |
reset | 停止计时器并恢复到设定的时间 |
plus1、plus2、plus5 | 增加 1、2 或 5 分钟 |
minus1、minus2、minus5 | 减少 1、2 或 5 分钟 |
preset5、preset10、preset15、preset20 | 停止计时器并设为 5、10、15 或 20 分钟 |
set | 停止计时器并设为 minutes 指定的分钟数,取值为 1 到 999 之间的整数 |
- 每条命令都会收到
timerActionResult({ action, id, hosts },hosts的含义与幻灯片命令相同)或error作为回应。命令已执行的证据是主机的下一个countdown或countdownPhase事件。 - 计时器仅在最新
controls事件中包含timer: true的主机上运行。运行旧版 Cliqer 的主机不会声明该功能,并会忽略计时器命令。 - 计时器命令与幻灯片命令遵循相同规则:仅当主机允许来自您连接的点击时才会执行,并且与幻灯片命令共享每秒 10 条命令的额度。
- 主机的计时器设置照常生效:设为正计时的计时器会正计时,跟随已连接硬件计时器的计时器会从该设备获取时间。
激光笔
在主机的共享屏幕上显示激光点,与演讲者手机的效果相同。
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
x和y是相对于共享屏幕左上角的位置,取值范围为0到1。- 将
{ "pressed": false }作为载荷发送即可隐藏激光点。否则,激光点会在您最后一次发送位置后 30 到 90 秒消失。 - 仅当主机正在共享屏幕并开启了激光笔时,激光点才会显示。其颜色由您的
presenterName决定。 - 每秒最多发送 30 个位置。多余的位置会被直接丢弃,不会返回错误,也没有结果事件。
向主机发送消息
向主机操作员发送消息。消息会显示在主机的演讲者聊天中,与来自演讲者手机的消息相同,操作员的回复会以 messageFromHost 返回。
# Join as an API client, then send a message. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"messageToHosts","payload":{"text":"Two minutes to the break","id":"m1"}}'
sleep 30 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
// After joining (see Slide Navigation): send a message, print the replies.
send({ type: 'messageToHosts', payload: { text: 'Two minutes to the break', id: 'm1' } })
ws.addEventListener('message', (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'messageToHostsResult') console.log(`Delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'messageFromHost') console.log(`${msg.payload.fromPresenterName}: ${msg.payload.originalPayload.text}`)
})
# After joining (see Slide Navigation): send a message.
send(ws, {"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}})
# In on_message, print the replies:
# elif msg["type"] == "messageFromHost":
# print(f"{msg['payload']['fromPresenterName']}: {msg['payload']['originalPayload']['text']}")
// After joining (see Slide Navigation): send a message.
send(map[string]interface{}{
"type": "messageToHosts",
"payload": map[string]interface{}{"text": "Two minutes to the break", "id": "m1"},
})
// In the read loop, print the replies:
// case "messageFromHost":
// fmt.Printf("%s\n", msg.Payload)
// After joining (see Slide Navigation): send a message.
$client->text(json_encode([
'type' => 'messageToHosts',
'payload' => ['text' => 'Two minutes to the break', 'id' => 'm1']
]));
// In the read loop, print the replies:
// } elseif ($msg['type'] === 'messageFromHost') {
// echo $msg['payload']['originalPayload']['text'], PHP_EOL;
// After joining (see Slide Navigation): send a message.
let message = json!({"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}});
write.send(Message::Text(message.to_string().into())).await?;
// In the read loop, print the replies:
// Some("messageFromHost") => println!("{}", msg["payload"]["originalPayload"]["text"]),
主机的回复:
{
"type": "messageFromHost",
"payload": {
"fromClientId": "host_abc123",
"fromPresenterName": "Stage PC",
"originalPayload": { "text": "Thanks, got it" }
},
"hostId": "host_abc123"
}
text为 1 到 1000 个字符。每 10 秒最多发送 5 条消息。- 每条消息都会收到
messageToHostsResult({ id, hosts })或error作为回应。 - 操作员会在您的
presenterName下看到该消息;主机的通知设置开启时,主机会显示通知。
事件参考
发出的事件(客户端 → 服务器)
| 事件 | 说明 |
|---|---|
joinRoom | 以 api: true 作为 API 客户端加入房间(参见幻灯片导航) |
webAction | 幻灯片控制(参见幻灯片导航) |
timerAction | 计时器控制(参见计时器) |
presenterMouse | 激光笔位置(参见激光笔) |
messageToHosts | 向主机操作员发送消息(参见向主机发送消息) |
logout | 离开房间;连接保持打开,可以再次加入 |
接收的事件(服务器 → 客户端)
| 事件 | 说明 |
|---|---|
connected | 连接打开时发送,包含 { clientId, userId, isAuthenticated } |
joinedRoom | 已成功加入房间 |
clientJoined | 有人加入了房间 |
clientLeft | 有人离开了房间 |
webActionResult | webAction 已送达房间的主机 |
timerActionResult | timerAction 已送达房间的主机 |
messageToHostsResult | messageToHosts 已送达房间的主机 |
clicksEnabled | 主机是否接收您的命令 |
controls | 主机的演示应用和计时器支持的功能 |
slide | 命令执行后主机的当前幻灯片 |
countdown | 主机计时器上的时间 |
countdownPhase | 主机计时器的阶段 |
messageFromHost | 来自主机操作员的消息 |
logout | 操作员移除或屏蔽了您的连接(reason: "kicked") |
error | 错误消息 |