211 lines
6.1 KiB
GDScript
211 lines
6.1 KiB
GDScript
extends Node
|
|
class_name WsGatewayClient
|
|
## WebSocket gateway client for real-time presence and chat.
|
|
##
|
|
## Manages connection to the AGP gateway endpoint (/api/ws or /v1/ws).
|
|
## Handles JWT authentication, presence heartbeat, DM/world channel
|
|
## subscriptions, and event dispatch.
|
|
|
|
signal connected
|
|
signal disconnected
|
|
signal authenticated(user_id: int, username: String)
|
|
signal presence_changed(user_id: int, status: String)
|
|
signal chat_message_received(channel: String, id: int, data: Dictionary)
|
|
signal subscribed(channel: String, id: int)
|
|
signal unsubscribed(channel: String, id: int)
|
|
signal gateway_error(message: String)
|
|
|
|
enum ConnectionState {
|
|
DISCONNECTED,
|
|
CONNECTING,
|
|
CONNECTED,
|
|
AUTHENTICATED,
|
|
CLOSING
|
|
}
|
|
|
|
var _peer: WebSocketPeer = WebSocketPeer.new()
|
|
var _state: ConnectionState = ConnectionState.DISCONNECTED
|
|
var _token: String = ""
|
|
var _authenticated_user_id: int = 0
|
|
var _authenticated_username: String = ""
|
|
var _heartbeat_timer: float = 0.0
|
|
var _heartbeat_interval: float = 25.0
|
|
var _last_url: String = ""
|
|
|
|
var is_connected_to_gateway: bool:
|
|
get:
|
|
return _state == ConnectionState.CONNECTED or _state == ConnectionState.AUTHENTICATED
|
|
|
|
var is_authenticated: bool:
|
|
get:
|
|
return _state == ConnectionState.AUTHENTICATED
|
|
|
|
var user_id: int:
|
|
get:
|
|
return _authenticated_user_id
|
|
|
|
var username: String:
|
|
get:
|
|
return _authenticated_username
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if _state == ConnectionState.DISCONNECTED:
|
|
return
|
|
|
|
_peer.poll()
|
|
var peer_state := _peer.get_ready_state()
|
|
|
|
match peer_state:
|
|
WebSocketPeer.STATE_OPEN:
|
|
if _state == ConnectionState.CONNECTING:
|
|
_state = ConnectionState.CONNECTED
|
|
connected.emit()
|
|
# If token was provided, send auth frame if not already sent via query param
|
|
if _token != "" and _state != ConnectionState.AUTHENTICATED:
|
|
send_json_message({"type": "auth", "token": _token})
|
|
|
|
# Process incoming packets
|
|
while _peer.get_available_packet_count() > 0:
|
|
var packet := _peer.get_packet()
|
|
_handle_packet(packet)
|
|
|
|
# Send periodic heartbeat
|
|
if _state == ConnectionState.AUTHENTICATED:
|
|
_heartbeat_timer += delta
|
|
if _heartbeat_timer >= _heartbeat_interval:
|
|
_heartbeat_timer = 0.0
|
|
send_heartbeat()
|
|
|
|
WebSocketPeer.STATE_CLOSING:
|
|
_state = ConnectionState.CLOSING
|
|
|
|
WebSocketPeer.STATE_CLOSED:
|
|
if _state != ConnectionState.DISCONNECTED:
|
|
var code := _peer.get_close_code()
|
|
var reason := _peer.get_close_reason()
|
|
_cleanup()
|
|
disconnected.emit()
|
|
|
|
|
|
## Connect to the WebSocket gateway.
|
|
## If ws_url is empty, derives ws:// or wss:// from base_http_url + "/api/ws".
|
|
func connect_gateway(base_http_url: String = "http://127.0.0.1:8000", token: String = "") -> Error:
|
|
_cleanup()
|
|
_token = token
|
|
|
|
var url: String
|
|
if base_http_url.begins_with("ws://") or base_http_url.begins_with("wss://"):
|
|
url = base_http_url
|
|
else:
|
|
var ws_scheme := "wss://" if base_http_url.begins_with("https://") else "ws://"
|
|
var stripped := base_http_url.replace("https://", "").replace("http://", "").rstrip("/")
|
|
url = ws_scheme + stripped + "/api/ws"
|
|
|
|
if token != "":
|
|
url += "?token=" + token.uri_encode()
|
|
|
|
_last_url = url
|
|
_peer = WebSocketPeer.new()
|
|
var err := _peer.connect_to_url(url)
|
|
if err == OK:
|
|
_state = ConnectionState.CONNECTING
|
|
else:
|
|
_state = ConnectionState.DISCONNECTED
|
|
gateway_error.emit("Failed to initiate connection: %d" % err)
|
|
return err
|
|
|
|
|
|
## Disconnect from the WebSocket gateway.
|
|
func disconnect_gateway(code: int = 1000, reason: String = "Normal closure") -> void:
|
|
if _state != ConnectionState.DISCONNECTED and _peer != null:
|
|
_peer.close(code, reason)
|
|
_cleanup()
|
|
disconnected.emit()
|
|
|
|
|
|
## Send a ping frame.
|
|
func send_ping() -> void:
|
|
send_json_message({"type": "ping"})
|
|
|
|
|
|
## Send presence heartbeat.
|
|
func send_heartbeat() -> void:
|
|
send_json_message({"type": "heartbeat"})
|
|
|
|
|
|
## Subscribe to world chat events.
|
|
func subscribe_world(world_id: int) -> void:
|
|
send_json_message({"type": "subscribe", "channel": "world", "id": world_id})
|
|
|
|
|
|
## Unsubscribe from world chat events.
|
|
func unsubscribe_world(world_id: int) -> void:
|
|
send_json_message({"type": "unsubscribe", "channel": "world", "id": world_id})
|
|
|
|
|
|
## Subscribe to DM conversation events.
|
|
func subscribe_dm(conversation_id: int) -> void:
|
|
send_json_message({"type": "subscribe", "channel": "dm", "id": conversation_id})
|
|
|
|
|
|
## Unsubscribe from DM conversation events.
|
|
func unsubscribe_dm(conversation_id: int) -> void:
|
|
send_json_message({"type": "unsubscribe", "channel": "dm", "id": conversation_id})
|
|
|
|
|
|
## Send any dictionary as a JSON text packet.
|
|
func send_json_message(data: Dictionary) -> Error:
|
|
if _peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
|
return ERR_UNCONFIGURED
|
|
var text := JSON.stringify(data)
|
|
return _peer.send_text(text)
|
|
|
|
|
|
func _handle_packet(packet: PackedByteArray) -> void:
|
|
var text := packet.get_string_from_utf8()
|
|
var parsed: Variant = JSON.parse_string(text)
|
|
if not (parsed is Dictionary):
|
|
return
|
|
var msg: Dictionary = parsed as Dictionary
|
|
var msg_type: String = str(msg.get("type", ""))
|
|
|
|
match msg_type:
|
|
"auth_ok":
|
|
_state = ConnectionState.AUTHENTICATED
|
|
_authenticated_user_id = int(msg.get("user_id", 0))
|
|
_authenticated_username = str(msg.get("username", ""))
|
|
authenticated.emit(_authenticated_user_id, _authenticated_username)
|
|
|
|
"presence":
|
|
var uid: int = int(msg.get("user_id", 0))
|
|
var p_status: String = str(msg.get("status", "offline"))
|
|
presence_changed.emit(uid, p_status)
|
|
|
|
"chat_message":
|
|
var channel: String = str(msg.get("channel", ""))
|
|
var target_id: int = int(msg.get("id", 0))
|
|
var data: Dictionary = msg.get("data", {}) if msg.get("data") is Dictionary else {}
|
|
chat_message_received.emit(channel, target_id, data)
|
|
|
|
"subscribed":
|
|
var channel: String = str(msg.get("channel", ""))
|
|
var id: int = int(msg.get("id", 0))
|
|
subscribed.emit(channel, id)
|
|
|
|
"unsubscribed":
|
|
var channel: String = str(msg.get("channel", ""))
|
|
var id: int = int(msg.get("id", 0))
|
|
unsubscribed.emit(channel, id)
|
|
|
|
"error":
|
|
var err_msg: String = str(msg.get("message", "Unknown error"))
|
|
gateway_error.emit(err_msg)
|
|
|
|
|
|
func _cleanup() -> void:
|
|
_state = ConnectionState.DISCONNECTED
|
|
_authenticated_user_id = 0
|
|
_authenticated_username = ""
|
|
_heartbeat_timer = 0.0
|