feat(gateway): add core gateway protocol implementation
新建了完整的网关协议模块,包含版本协商、基础类型定义、信道请求模型、错误码规范、通信帧结构以及握手流程,实现了完整的客户端-服务端通信协议基础框架
This commit is contained in:
parent
7a972055b7
commit
fee71576a2
39
backend/package/yuxi/gateway/__init__.py
Normal file
39
backend/package/yuxi/gateway/__init__.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from yuxi.gateway.protocol import (
|
||||||
|
ChannelLogoutRequest,
|
||||||
|
ChannelStartRequest,
|
||||||
|
ChannelStatusRequest,
|
||||||
|
ConnectParams,
|
||||||
|
ErrorCode,
|
||||||
|
ErrorDetail,
|
||||||
|
EventFrame,
|
||||||
|
GatewayFrame,
|
||||||
|
HelloFeatures,
|
||||||
|
HelloOk,
|
||||||
|
NonEmptyString,
|
||||||
|
PositiveInt,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
RequestFrame,
|
||||||
|
ResponseFrame,
|
||||||
|
handle_handshake,
|
||||||
|
negotiate_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RequestFrame",
|
||||||
|
"ResponseFrame",
|
||||||
|
"EventFrame",
|
||||||
|
"GatewayFrame",
|
||||||
|
"ErrorCode",
|
||||||
|
"ErrorDetail",
|
||||||
|
"PROTOCOL_VERSION",
|
||||||
|
"negotiate_version",
|
||||||
|
"ConnectParams",
|
||||||
|
"HelloFeatures",
|
||||||
|
"HelloOk",
|
||||||
|
"handle_handshake",
|
||||||
|
"NonEmptyString",
|
||||||
|
"PositiveInt",
|
||||||
|
"ChannelStatusRequest",
|
||||||
|
"ChannelStartRequest",
|
||||||
|
"ChannelLogoutRequest",
|
||||||
|
]
|
||||||
37
backend/package/yuxi/gateway/protocol/__init__.py
Normal file
37
backend/package/yuxi/gateway/protocol/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from yuxi.gateway.protocol.channels import ChannelLogoutRequest, ChannelStartRequest, ChannelStatusRequest
|
||||||
|
from yuxi.gateway.protocol.errors import ErrorCode, ErrorDetail
|
||||||
|
from yuxi.gateway.protocol.frames import EventFrame, RequestFrame, ResponseFrame
|
||||||
|
from yuxi.gateway.protocol.handshake import ConnectParams, HelloFeatures, HelloOk, handle_handshake
|
||||||
|
from yuxi.gateway.protocol.primitives import NonEmptyString, PositiveInt
|
||||||
|
from yuxi.gateway.protocol.version import PROTOCOL_VERSION, negotiate_version
|
||||||
|
|
||||||
|
GatewayFrame = Annotated[
|
||||||
|
RequestFrame | ResponseFrame | EventFrame,
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RequestFrame",
|
||||||
|
"ResponseFrame",
|
||||||
|
"EventFrame",
|
||||||
|
"GatewayFrame",
|
||||||
|
"ErrorCode",
|
||||||
|
"ErrorDetail",
|
||||||
|
"PROTOCOL_VERSION",
|
||||||
|
"negotiate_version",
|
||||||
|
"ConnectParams",
|
||||||
|
"HelloFeatures",
|
||||||
|
"HelloOk",
|
||||||
|
"handle_handshake",
|
||||||
|
"NonEmptyString",
|
||||||
|
"PositiveInt",
|
||||||
|
"ChannelStatusRequest",
|
||||||
|
"ChannelStartRequest",
|
||||||
|
"ChannelLogoutRequest",
|
||||||
|
]
|
||||||
19
backend/package/yuxi/gateway/protocol/channels.py
Normal file
19
backend/package/yuxi/gateway/protocol/channels.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelStatusRequest(BaseModel):
|
||||||
|
channel_id: str | None = None
|
||||||
|
account_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelStartRequest(BaseModel):
|
||||||
|
channel_id: str
|
||||||
|
account_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelLogoutRequest(BaseModel):
|
||||||
|
channel_id: str
|
||||||
|
account_id: str | None = None
|
||||||
|
logged_out: bool = True
|
||||||
27
backend/package/yuxi/gateway/protocol/errors.py
Normal file
27
backend/package/yuxi/gateway/protocol/errors.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(IntEnum):
|
||||||
|
INVALID_REQUEST = -32600
|
||||||
|
METHOD_NOT_FOUND = -32601
|
||||||
|
INVALID_PARAMS = -32602
|
||||||
|
INTERNAL_ERROR = -32603
|
||||||
|
RATE_LIMITED = -32000
|
||||||
|
AUTH_REQUIRED = -32001
|
||||||
|
NOT_LINKED = -32002
|
||||||
|
NOT_PAIRED = -32003
|
||||||
|
AGENT_TIMEOUT = -32004
|
||||||
|
APPROVAL_NOT_FOUND = -32005
|
||||||
|
UNAVAILABLE = -32006
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorDetail(BaseModel):
|
||||||
|
code: ErrorCode
|
||||||
|
message: str
|
||||||
|
details: str | None = None
|
||||||
|
retryable: bool = False
|
||||||
|
retry_after_ms: int | None = None
|
||||||
36
backend/package/yuxi/gateway/protocol/frames.py
Normal file
36
backend/package/yuxi/gateway/protocol/frames.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from yuxi.gateway.protocol.errors import ErrorDetail
|
||||||
|
|
||||||
|
|
||||||
|
class RequestFrame(BaseModel):
|
||||||
|
type: Literal["req"] = "req"
|
||||||
|
id: str
|
||||||
|
method: str
|
||||||
|
params: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseFrame(BaseModel):
|
||||||
|
type: Literal["res"] = "res"
|
||||||
|
id: str
|
||||||
|
ok: bool
|
||||||
|
payload: Any = None
|
||||||
|
error: ErrorDetail | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventFrame(BaseModel):
|
||||||
|
type: Literal["event"] = "event"
|
||||||
|
event: str
|
||||||
|
payload: Any = None
|
||||||
|
seq: int = 0
|
||||||
|
state_version: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
from yuxi.gateway.protocol.errors import ErrorDetail # noqa: E402
|
||||||
|
|
||||||
|
ResponseFrame.model_rebuild()
|
||||||
75
backend/package/yuxi/gateway/protocol/handshake.py
Normal file
75
backend/package/yuxi/gateway/protocol/handshake.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from yuxi.gateway.protocol.version import negotiate_version
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectParams(BaseModel):
|
||||||
|
min_protocol: int = 1
|
||||||
|
max_protocol: int = 1
|
||||||
|
client_id: str = ""
|
||||||
|
client_version: str = ""
|
||||||
|
client_platform: str = ""
|
||||||
|
auth_token: str | None = None
|
||||||
|
caps: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
client_display_name: str = ""
|
||||||
|
client_device_family: str = ""
|
||||||
|
client_model_identifier: str = ""
|
||||||
|
client_mode: str = "default"
|
||||||
|
client_instance_id: str = ""
|
||||||
|
|
||||||
|
commands: list[str] = Field(default_factory=list)
|
||||||
|
permissions: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
path_env: str = ""
|
||||||
|
role: str = ""
|
||||||
|
scopes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
locale: str = ""
|
||||||
|
user_agent: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class HelloFeatures(BaseModel):
|
||||||
|
methods: list[str] = Field(default_factory=list)
|
||||||
|
events: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class HelloOk(BaseModel):
|
||||||
|
type: Literal["hello-ok"] = "hello-ok"
|
||||||
|
protocol: int
|
||||||
|
server_version: str
|
||||||
|
server_id: str
|
||||||
|
session_id: str
|
||||||
|
features: HelloFeatures = Field(default_factory=HelloFeatures)
|
||||||
|
snapshot: dict | None = None
|
||||||
|
dm_policy: str = "pairing"
|
||||||
|
group_policy: str = "allowlist"
|
||||||
|
conn_id: str = ""
|
||||||
|
max_payload: int = 1048576
|
||||||
|
max_buffered_bytes: int = 10485760
|
||||||
|
tick_interval_ms: int = 5000
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_handshake(params: ConnectParams) -> HelloOk:
|
||||||
|
protocol = negotiate_version(params.min_protocol, params.max_protocol)
|
||||||
|
if protocol is None:
|
||||||
|
raise ValueError("Protocol version negotiation failed")
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
conn_id = f"conn-{session_id[:8]}"
|
||||||
|
return HelloOk(
|
||||||
|
protocol=protocol,
|
||||||
|
server_version="1.0.0",
|
||||||
|
server_id="forcepilot-gateway",
|
||||||
|
session_id=session_id,
|
||||||
|
conn_id=conn_id,
|
||||||
|
features=HelloFeatures(
|
||||||
|
methods=["channel.status", "channel.start", "channel.stop", "chat.send", "chat.abort"],
|
||||||
|
events=["channel.status_change", "tick", "chat", "channel.logout", "config.reload"],
|
||||||
|
),
|
||||||
|
)
|
||||||
14
backend/package/yuxi/gateway/protocol/primitives.py
Normal file
14
backend/package/yuxi/gateway/protocol/primitives.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import AfterValidator, StringConstraints
|
||||||
|
|
||||||
|
NonEmptyString = Annotated[str, StringConstraints(min_length=1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_positive(v: int) -> int:
|
||||||
|
return v if v >= 1 else 1
|
||||||
|
|
||||||
|
|
||||||
|
PositiveInt = Annotated[int, AfterValidator(_ensure_positive)]
|
||||||
13
backend/package/yuxi/gateway/protocol/version.py
Normal file
13
backend/package/yuxi/gateway/protocol/version.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
PROTOCOL_VERSION: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
def negotiate_version(client_min: int, client_max: int) -> int | None:
|
||||||
|
server_max = PROTOCOL_VERSION
|
||||||
|
server_min = 1
|
||||||
|
|
||||||
|
negotiated = min(client_max, server_max)
|
||||||
|
if negotiated >= max(client_min, server_min):
|
||||||
|
return negotiated
|
||||||
|
return None
|
||||||
Loading…
Reference in New Issue
Block a user