WebSocket リアルタイム

wss://cliqer.io/ws に接続すると、独自のソフトウェアやショーコントロールシステムから、リモートのプレゼンリモコンのように Cliqer のルームを操作できます。スライドの切り替え、ホストのタイマーの操作、レーザーポイントでの指し示し、ホストへのメッセージ送信が可能です。アプリは発表者としてルームに参加します。ルームのホストは常に Cliqer デスクトップアプリが務めます。
session_token Cookie(ブラウザーが自動的に送信します)、?token= クエリパラメーター、または Authorization: Bearer ヘッダーを使用します。トークンは接続時に検証され、コマンドごとには検証されません。そのため、トークンの有効期限が切れたら、新しいトークンで新しい接続を開き、再度参加してください。トークンなしでも接続はできますが、API は使用できません。チーム API キー(cliqer_...)は REST エンドポイントの認証専用です。このソケットでは、キーはセッショントークンとして扱われません。接続
# 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のコマンドを破棄するため、同じコマンドを再試行しても実行されるのは 1 回だけです。first、last、goto、blackoutは、最新のcontrolsイベントのnavにそれらが含まれているホストでのみ実行されます。controlsイベントを送信しないホストはnextとprevのみに対応します。ルームに複数のホストがいる場合、各ホストは自身が対応するコマンドを実行します。- 画面が黒の状態で
nextまたはprevを送ると、スライドを移動せずに画面を再表示します。 - 最後のスライドを超える
gotoは無視されます。 - コマンドの送信は、スライドとタイマーのコマンドを合わせて 1 秒あたり最大 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 | 1 秒間にスライドとタイマーのコマンドが 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" } }
ホストからのイベントには、そのホストの ID が hostId に含まれます:
| イベント | ペイロード |
|---|---|
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(残り 1 分未満)、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 を実行しているホストはこれを通知せず、タイマーコマンドを無視します。 - タイマーコマンドには、スライドコマンドと同じルールが適用されます。ホストがこの接続からのクリックを許可している間のみ実行され、1 秒あたり 10 件の上限をスライドコマンドと共有します。
- ホストのタイマー設定はそのまま適用されます。カウントアップに設定されたタイマーはカウントアップし、接続されたハードウェアタイマーに連動するタイマーは、そのデバイスから時間を取得します。
レーザーポインター
発表者のスマートフォンと同じように、ホストの共有画面にレーザーポイントを表示します。
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
xとyは、共有画面の左上隅を基準とした位置で、0から1の範囲で指定します。- ポイントを非表示にするには、ペイロードとして
{ "pressed": false }を送信します。送信しない場合は、最後に位置を送信してから 30 から 90 秒後に消えます。 - ポイントは、ホストが画面を共有していて、レーザーポインターがオンになっている間のみ表示されます。色は
presenterNameに応じて決まります。 - 位置の送信は 1 秒あたり最大 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 | エラーメッセージ |