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