Files
modbison/scripts/ws_gateway_client.gd

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