CLI: remove relay/heartbeat legacy commands

This commit is contained in:
Peter Steinberger
2025-12-09 15:06:44 +01:00
parent 172ce6c79f
commit d1217e84c7
4 changed files with 263 additions and 775 deletions

View File

@@ -182,7 +182,7 @@ actor GatewayChannel {
self.inner = GatewayChannelActor(url: url, token: token) self.inner = GatewayChannelActor(url: url, token: token)
} }
func request(method: String, params: [String: Any]?) async throws -> Data { func request(method: String, params: [String: AnyCodable]?) async throws -> Data {
guard let inner else { guard let inner else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "not configured"]) throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "not configured"])
} }

View File

@@ -6,6 +6,7 @@
import Foundation import Foundation
// MARK: - ClawdisGateway // MARK: - ClawdisGateway
/// Handshake, request/response, and event frames for the Gateway WebSocket. /// Handshake, request/response, and event frames for the Gateway WebSocket.
struct ClawdisGateway: Codable { struct ClawdisGateway: Codable {
let auth: Auth? let auth: Auth?
@@ -53,7 +54,7 @@ extension ClawdisGateway {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
@@ -81,9 +82,9 @@ extension ClawdisGateway {
payload: JSONAny?? = nil, payload: JSONAny?? = nil,
event: String?? = nil, event: String?? = nil,
seq: Int?? = nil, seq: Int?? = nil,
stateVersion: ClawdisGatewayStateVersion?? = nil stateVersion: ClawdisGatewayStateVersion?? = nil) -> ClawdisGateway
) -> ClawdisGateway { {
return ClawdisGateway( ClawdisGateway(
auth: auth ?? self.auth, auth: auth ?? self.auth,
caps: caps ?? self.caps, caps: caps ?? self.caps,
client: client ?? self.client, client: client ?? self.client,
@@ -108,20 +109,20 @@ extension ClawdisGateway {
payload: payload ?? self.payload, payload: payload ?? self.payload,
event: event ?? self.event, event: event ?? self.event,
seq: seq ?? self.seq, seq: seq ?? self.seq,
stateVersion: stateVersion ?? self.stateVersion stateVersion: stateVersion ?? self.stateVersion)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Auth // MARK: - Auth
struct Auth: Codable { struct Auth: Codable {
let token: String? let token: String?
} }
@@ -141,27 +142,27 @@ extension Auth {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
token: String?? = nil token: String?? = nil) -> Auth
) -> Auth { {
return Auth( Auth(
token: token ?? self.token token: token ?? self.token)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Client // MARK: - Client
struct Client: Codable { struct Client: Codable {
let instanceID: String? let instanceID: String?
let mode, name, platform, version: String let mode, name, platform, version: String
@@ -187,7 +188,7 @@ extension Client {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
@@ -195,27 +196,27 @@ extension Client {
mode: String? = nil, mode: String? = nil,
name: String? = nil, name: String? = nil,
platform: String? = nil, platform: String? = nil,
version: String? = nil version: String? = nil) -> Client
) -> Client { {
return Client( Client(
instanceID: instanceID ?? self.instanceID, instanceID: instanceID ?? self.instanceID,
mode: mode ?? self.mode, mode: mode ?? self.mode,
name: name ?? self.name, name: name ?? self.name,
platform: platform ?? self.platform, platform: platform ?? self.platform,
version: version ?? self.version version: version ?? self.version)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Error // MARK: - Error
struct Error: Codable { struct Error: Codable {
let code: String let code: String
let details: JSONAny? let details: JSONAny?
@@ -244,7 +245,7 @@ extension Error {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
@@ -252,27 +253,27 @@ extension Error {
details: JSONAny?? = nil, details: JSONAny?? = nil,
message: String? = nil, message: String? = nil,
retryable: Bool?? = nil, retryable: Bool?? = nil,
retryAfterMS: Int?? = nil retryAfterMS: Int?? = nil) -> Error
) -> Error { {
return Error( Error(
code: code ?? self.code, code: code ?? self.code,
details: details ?? self.details, details: details ?? self.details,
message: message ?? self.message, message: message ?? self.message,
retryable: retryable ?? self.retryable, retryable: retryable ?? self.retryable,
retryAfterMS: retryAfterMS ?? self.retryAfterMS retryAfterMS: retryAfterMS ?? self.retryAfterMS)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Features // MARK: - Features
struct Features: Codable { struct Features: Codable {
let events, methods: [String] let events, methods: [String]
} }
@@ -292,29 +293,29 @@ extension Features {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
events: [String]? = nil, events: [String]? = nil,
methods: [String]? = nil methods: [String]? = nil) -> Features
) -> Features { {
return Features( Features(
events: events ?? self.events, events: events ?? self.events,
methods: methods ?? self.methods methods: methods ?? self.methods)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Policy // MARK: - Policy
struct Policy: Codable { struct Policy: Codable {
let maxBufferedBytes, maxPayload, tickIntervalMS: Int let maxBufferedBytes, maxPayload, tickIntervalMS: Int
@@ -339,31 +340,31 @@ extension Policy {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
maxBufferedBytes: Int? = nil, maxBufferedBytes: Int? = nil,
maxPayload: Int? = nil, maxPayload: Int? = nil,
tickIntervalMS: Int? = nil tickIntervalMS: Int? = nil) -> Policy
) -> Policy { {
return Policy( Policy(
maxBufferedBytes: maxBufferedBytes ?? self.maxBufferedBytes, maxBufferedBytes: maxBufferedBytes ?? self.maxBufferedBytes,
maxPayload: maxPayload ?? self.maxPayload, maxPayload: maxPayload ?? self.maxPayload,
tickIntervalMS: tickIntervalMS ?? self.tickIntervalMS tickIntervalMS: tickIntervalMS ?? self.tickIntervalMS)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Server // MARK: - Server
struct Server: Codable { struct Server: Codable {
let commit: String? let commit: String?
let connID: String let connID: String
@@ -392,33 +393,33 @@ extension Server {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
commit: String?? = nil, commit: String?? = nil,
connID: String? = nil, connID: String? = nil,
host: String?? = nil, host: String?? = nil,
version: String? = nil version: String? = nil) -> Server
) -> Server { {
return Server( Server(
commit: commit ?? self.commit, commit: commit ?? self.commit,
connID: connID ?? self.connID, connID: connID ?? self.connID,
host: host ?? self.host, host: host ?? self.host,
version: version ?? self.version version: version ?? self.version)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Snapshot // MARK: - Snapshot
struct Snapshot: Codable { struct Snapshot: Codable {
let health: JSONAny let health: JSONAny
let presence: [Presence] let presence: [Presence]
@@ -446,33 +447,33 @@ extension Snapshot {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
health: JSONAny? = nil, health: JSONAny? = nil,
presence: [Presence]? = nil, presence: [Presence]? = nil,
stateVersion: SnapshotStateVersion? = nil, stateVersion: SnapshotStateVersion? = nil,
uptimeMS: Int? = nil uptimeMS: Int? = nil) -> Snapshot
) -> Snapshot { {
return Snapshot( Snapshot(
health: health ?? self.health, health: health ?? self.health,
presence: presence ?? self.presence, presence: presence ?? self.presence,
stateVersion: stateVersion ?? self.stateVersion, stateVersion: stateVersion ?? self.stateVersion,
uptimeMS: uptimeMS ?? self.uptimeMS uptimeMS: uptimeMS ?? self.uptimeMS)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - Presence // MARK: - Presence
struct Presence: Codable { struct Presence: Codable {
let host, instanceID, ip: String? let host, instanceID, ip: String?
let lastInputSeconds: Int? let lastInputSeconds: Int?
@@ -504,7 +505,7 @@ extension Presence {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
@@ -517,9 +518,9 @@ extension Presence {
tags: [String]?? = nil, tags: [String]?? = nil,
text: String?? = nil, text: String?? = nil,
ts: Int? = nil, ts: Int? = nil,
version: String?? = nil version: String?? = nil) -> Presence
) -> Presence { {
return Presence( Presence(
host: host ?? self.host, host: host ?? self.host,
instanceID: instanceID ?? self.instanceID, instanceID: instanceID ?? self.instanceID,
ip: ip ?? self.ip, ip: ip ?? self.ip,
@@ -529,20 +530,20 @@ extension Presence {
tags: tags ?? self.tags, tags: tags ?? self.tags,
text: text ?? self.text, text: text ?? self.text,
ts: ts ?? self.ts, ts: ts ?? self.ts,
version: version ?? self.version version: version ?? self.version)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - SnapshotStateVersion // MARK: - SnapshotStateVersion
struct SnapshotStateVersion: Codable { struct SnapshotStateVersion: Codable {
let health, presence: Int let health, presence: Int
} }
@@ -562,29 +563,29 @@ extension SnapshotStateVersion {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
health: Int? = nil, health: Int? = nil,
presence: Int? = nil presence: Int? = nil) -> SnapshotStateVersion
) -> SnapshotStateVersion { {
return SnapshotStateVersion( SnapshotStateVersion(
health: health ?? self.health, health: health ?? self.health,
presence: presence ?? self.presence presence: presence ?? self.presence)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
// MARK: - ClawdisGatewayStateVersion // MARK: - ClawdisGatewayStateVersion
struct ClawdisGatewayStateVersion: Codable { struct ClawdisGatewayStateVersion: Codable {
let health, presence: Int let health, presence: Int
} }
@@ -604,35 +605,34 @@ extension ClawdisGatewayStateVersion {
} }
init(fromURL url: URL) throws { init(fromURL url: URL) throws {
try self.init(data: try Data(contentsOf: url)) try self.init(data: Data(contentsOf: url))
} }
func with( func with(
health: Int? = nil, health: Int? = nil,
presence: Int? = nil presence: Int? = nil) -> ClawdisGatewayStateVersion
) -> ClawdisGatewayStateVersion { {
return ClawdisGatewayStateVersion( ClawdisGatewayStateVersion(
health: health ?? self.health, health: health ?? self.health,
presence: presence ?? self.presence presence: presence ?? self.presence)
)
} }
func jsonData() throws -> Data { func jsonData() throws -> Data {
return try newJSONEncoder().encode(self) try newJSONEncoder().encode(self)
} }
func jsonString(encoding: String.Encoding = .utf8) throws -> String? { func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
return String(data: try self.jsonData(), encoding: encoding) try String(data: self.jsonData(), encoding: encoding)
} }
} }
enum TypeEnum: String, Codable { enum TypeEnum: String, Codable {
case event = "event" case event
case hello = "hello" case hello
case helloError = "hello-error" case helloError = "hello-error"
case helloOk = "hello-ok" case helloOk = "hello-ok"
case req = "req" case req
case res = "res" case res
} }
// MARK: - Helper functions for creating encoders and decoders // MARK: - Helper functions for creating encoders and decoders
@@ -656,18 +656,17 @@ func newJSONEncoder() -> JSONEncoder {
// MARK: - Encode/decode helpers // MARK: - Encode/decode helpers
class JSONNull: Codable, Hashable { class JSONNull: Codable, Hashable {
static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
true true
} }
public func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(0) hasher.combine(0)
} }
public init() {} init() {}
public required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if !container.decodeNil() { if !container.decodeNil() {
throw DecodingError.typeMismatch( throw DecodingError.typeMismatch(
@@ -678,7 +677,7 @@ class JSONNull: Codable, Hashable {
} }
} }
public func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encodeNil() try container.encodeNil()
} }
@@ -688,215 +687,215 @@ class JSONCodingKey: CodingKey {
let key: String let key: String
required init?(intValue: Int) { required init?(intValue: Int) {
return nil nil
} }
required init?(stringValue: String) { required init?(stringValue: String) {
key = stringValue self.key = stringValue
} }
var intValue: Int? { var intValue: Int? {
return nil nil
} }
var stringValue: String { var stringValue: String {
return key self.key
} }
} }
class JSONAny: Codable { class JSONAny: Codable {
let value: Any let value: Any
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError { static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny") let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
return DecodingError.typeMismatch(JSONAny.self, context) return DecodingError.typeMismatch(JSONAny.self, context)
} }
static func encodingError(forValue value: Any, codingPath: [CodingKey]) -> EncodingError { static func encodingError(forValue value: Any, codingPath: [CodingKey]) -> EncodingError {
let context = EncodingError.Context(codingPath: codingPath, debugDescription: "Cannot encode JSONAny") let context = EncodingError.Context(codingPath: codingPath, debugDescription: "Cannot encode JSONAny")
return EncodingError.invalidValue(value, context) return EncodingError.invalidValue(value, context)
} }
static func decode(from container: SingleValueDecodingContainer) throws -> Any { static func decode(from container: SingleValueDecodingContainer) throws -> Any {
if let value = try? container.decode(Bool.self) { if let value = try? container.decode(Bool.self) {
return value return value
} }
if let value = try? container.decode(Int64.self) { if let value = try? container.decode(Int64.self) {
return value return value
} }
if let value = try? container.decode(Double.self) { if let value = try? container.decode(Double.self) {
return value return value
} }
if let value = try? container.decode(String.self) { if let value = try? container.decode(String.self) {
return value return value
} }
if container.decodeNil() { if container.decodeNil() {
return JSONNull() return JSONNull()
} }
throw decodingError(forCodingPath: container.codingPath) throw self.decodingError(forCodingPath: container.codingPath)
} }
static func decode(from container: inout UnkeyedDecodingContainer) throws -> Any { static func decode(from container: inout UnkeyedDecodingContainer) throws -> Any {
if let value = try? container.decode(Bool.self) { if let value = try? container.decode(Bool.self) {
return value return value
}
if let value = try? container.decode(Int64.self) {
return value
}
if let value = try? container.decode(Double.self) {
return value
}
if let value = try? container.decode(String.self) {
return value
}
if let value = try? container.decodeNil() {
if value {
return JSONNull()
} }
if let value = try? container.decode(Int64.self) { }
return value if var container = try? container.nestedUnkeyedContainer() {
} return try self.decodeArray(from: &container)
if let value = try? container.decode(Double.self) { }
return value if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self) {
} return try self.decodeDictionary(from: &container)
if let value = try? container.decode(String.self) { }
return value throw self.decodingError(forCodingPath: container.codingPath)
}
if let value = try? container.decodeNil() {
if value {
return JSONNull()
}
}
if var container = try? container.nestedUnkeyedContainer() {
return try decodeArray(from: &container)
}
if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self) {
return try decodeDictionary(from: &container)
}
throw decodingError(forCodingPath: container.codingPath)
} }
static func decode( static func decode(
from container: inout KeyedDecodingContainer<JSONCodingKey>, from container: inout KeyedDecodingContainer<JSONCodingKey>,
forKey key: JSONCodingKey) throws -> Any { forKey key: JSONCodingKey) throws -> Any
if let value = try? container.decode(Bool.self, forKey: key) { {
return value if let value = try? container.decode(Bool.self, forKey: key) {
return value
}
if let value = try? container.decode(Int64.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return value
}
if let value = try? container.decode(String.self, forKey: key) {
return value
}
if let value = try? container.decodeNil(forKey: key) {
if value {
return JSONNull()
} }
if let value = try? container.decode(Int64.self, forKey: key) { }
return value if var container = try? container.nestedUnkeyedContainer(forKey: key) {
} return try self.decodeArray(from: &container)
if let value = try? container.decode(Double.self, forKey: key) { }
return value if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) {
} return try self.decodeDictionary(from: &container)
if let value = try? container.decode(String.self, forKey: key) { }
return value throw self.decodingError(forCodingPath: container.codingPath)
}
if let value = try? container.decodeNil(forKey: key) {
if value {
return JSONNull()
}
}
if var container = try? container.nestedUnkeyedContainer(forKey: key) {
return try decodeArray(from: &container)
}
if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) {
return try decodeDictionary(from: &container)
}
throw decodingError(forCodingPath: container.codingPath)
} }
static func decodeArray(from container: inout UnkeyedDecodingContainer) throws -> [Any] { static func decodeArray(from container: inout UnkeyedDecodingContainer) throws -> [Any] {
var arr: [Any] = [] var arr: [Any] = []
while !container.isAtEnd { while !container.isAtEnd {
let value = try decode(from: &container) let value = try decode(from: &container)
arr.append(value) arr.append(value)
} }
return arr return arr
} }
static func decodeDictionary(from container: inout KeyedDecodingContainer<JSONCodingKey>) throws -> [String: Any] { static func decodeDictionary(from container: inout KeyedDecodingContainer<JSONCodingKey>) throws -> [String: Any] {
var dict = [String: Any]() var dict = [String: Any]()
for key in container.allKeys { for key in container.allKeys {
let value = try decode(from: &container, forKey: key) let value = try decode(from: &container, forKey: key)
dict[key.stringValue] = value dict[key.stringValue] = value
} }
return dict return dict
} }
static func encode(to container: inout UnkeyedEncodingContainer, array: [Any]) throws { static func encode(to container: inout UnkeyedEncodingContainer, array: [Any]) throws {
for value in array { for value in array {
if let value = value as? Bool { if let value = value as? Bool {
try container.encode(value) try container.encode(value)
} else if let value = value as? Int64 { } else if let value = value as? Int64 {
try container.encode(value) try container.encode(value)
} else if let value = value as? Double { } else if let value = value as? Double {
try container.encode(value) try container.encode(value)
} else if let value = value as? String { } else if let value = value as? String {
try container.encode(value) try container.encode(value)
} else if value is JSONNull { } else if value is JSONNull {
try container.encodeNil() try container.encodeNil()
} else if let value = value as? [Any] { } else if let value = value as? [Any] {
var container = container.nestedUnkeyedContainer() var container = container.nestedUnkeyedContainer()
try encode(to: &container, array: value) try self.encode(to: &container, array: value)
} else if let value = value as? [String: Any] { } else if let value = value as? [String: Any] {
var container = container.nestedContainer(keyedBy: JSONCodingKey.self) var container = container.nestedContainer(keyedBy: JSONCodingKey.self)
try encode(to: &container, dictionary: value) try self.encode(to: &container, dictionary: value)
} else { } else {
throw encodingError(forValue: value, codingPath: container.codingPath) throw self.encodingError(forValue: value, codingPath: container.codingPath)
}
} }
}
} }
static func encode(to container: inout KeyedEncodingContainer<JSONCodingKey>, dictionary: [String: Any]) throws { static func encode(to container: inout KeyedEncodingContainer<JSONCodingKey>, dictionary: [String: Any]) throws {
for (key, value) in dictionary { for (key, value) in dictionary {
let key = JSONCodingKey(stringValue: key)! let key = JSONCodingKey(stringValue: key)!
if let value = value as? Bool { if let value = value as? Bool {
try container.encode(value, forKey: key) try container.encode(value, forKey: key)
} else if let value = value as? Int64 { } else if let value = value as? Int64 {
try container.encode(value, forKey: key) try container.encode(value, forKey: key)
} else if let value = value as? Double { } else if let value = value as? Double {
try container.encode(value, forKey: key) try container.encode(value, forKey: key)
} else if let value = value as? String { } else if let value = value as? String {
try container.encode(value, forKey: key) try container.encode(value, forKey: key)
} else if value is JSONNull { } else if value is JSONNull {
try container.encodeNil(forKey: key) try container.encodeNil(forKey: key)
} else if let value = value as? [Any] { } else if let value = value as? [Any] {
var container = container.nestedUnkeyedContainer(forKey: key) var container = container.nestedUnkeyedContainer(forKey: key)
try encode(to: &container, array: value) try self.encode(to: &container, array: value)
} else if let value = value as? [String: Any] { } else if let value = value as? [String: Any] {
var container = container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) var container = container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key)
try encode(to: &container, dictionary: value) try self.encode(to: &container, dictionary: value)
} else { } else {
throw encodingError(forValue: value, codingPath: container.codingPath) throw self.encodingError(forValue: value, codingPath: container.codingPath)
}
} }
}
} }
static func encode(to container: inout SingleValueEncodingContainer, value: Any) throws { static func encode(to container: inout SingleValueEncodingContainer, value: Any) throws {
if let value = value as? Bool { if let value = value as? Bool {
try container.encode(value) try container.encode(value)
} else if let value = value as? Int64 { } else if let value = value as? Int64 {
try container.encode(value) try container.encode(value)
} else if let value = value as? Double { } else if let value = value as? Double {
try container.encode(value) try container.encode(value)
} else if let value = value as? String { } else if let value = value as? String {
try container.encode(value) try container.encode(value)
} else if value is JSONNull { } else if value is JSONNull {
try container.encodeNil() try container.encodeNil()
} else { } else {
throw encodingError(forValue: value, codingPath: container.codingPath) throw self.encodingError(forValue: value, codingPath: container.codingPath)
} }
} }
public required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
if var arrayContainer = try? decoder.unkeyedContainer() { if var arrayContainer = try? decoder.unkeyedContainer() {
self.value = try JSONAny.decodeArray(from: &arrayContainer) self.value = try JSONAny.decodeArray(from: &arrayContainer)
} else if var container = try? decoder.container(keyedBy: JSONCodingKey.self) { } else if var container = try? decoder.container(keyedBy: JSONCodingKey.self) {
self.value = try JSONAny.decodeDictionary(from: &container) self.value = try JSONAny.decodeDictionary(from: &container)
} else { } else {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
self.value = try JSONAny.decode(from: container) self.value = try JSONAny.decode(from: container)
} }
} }
public func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
if let arr = self.value as? [Any] { if let arr = self.value as? [Any] {
var container = encoder.unkeyedContainer() var container = encoder.unkeyedContainer()
try JSONAny.encode(to: &container, array: arr) try JSONAny.encode(to: &container, array: arr)
} else if let dict = self.value as? [String: Any] { } else if let dict = self.value as? [String: Any] {
var container = encoder.container(keyedBy: JSONCodingKey.self) var container = encoder.container(keyedBy: JSONCodingKey.self)
try JSONAny.encode(to: &container, dictionary: dict) try JSONAny.encode(to: &container, dictionary: dict)
} else { } else {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try JSONAny.encode(to: &container, value: self.value) try JSONAny.encode(to: &container, value: self.value)
} }
} }
} }

View File

@@ -3,10 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCommand = vi.fn(); const sendCommand = vi.fn();
const statusCommand = vi.fn(); const statusCommand = vi.fn();
const loginWeb = vi.fn(); const loginWeb = vi.fn();
const monitorWebProvider = vi.fn();
const logWebSelfId = vi.fn();
const waitForever = vi.fn();
const monitorTelegramProvider = vi.fn();
const startWebChatServer = vi.fn(async () => ({ port: 18788 })); const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
const ensureWebChatServerFromConfig = vi.fn(async () => ({ port: 18788 })); const ensureWebChatServerFromConfig = vi.fn(async () => ({ port: 18788 }));
@@ -23,10 +19,6 @@ vi.mock("../commands/status.js", () => ({ statusCommand }));
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("../provider-web.js", () => ({ vi.mock("../provider-web.js", () => ({
loginWeb, loginWeb,
monitorWebProvider,
}));
vi.mock("../telegram/monitor.js", () => ({
monitorTelegramProvider,
})); }));
vi.mock("../webchat/server.js", () => ({ vi.mock("../webchat/server.js", () => ({
startWebChatServer, startWebChatServer,
@@ -34,8 +26,7 @@ vi.mock("../webchat/server.js", () => ({
getWebChatServer: () => null, getWebChatServer: () => null,
})); }));
vi.mock("./deps.js", () => ({ vi.mock("./deps.js", () => ({
createDefaultDeps: () => ({ waitForever }), createDefaultDeps: () => ({}),
logWebSelfId,
})); }));
const { buildProgram } = await import("./program.js"); const { buildProgram } = await import("./program.js");
@@ -53,73 +44,6 @@ describe("cli program", () => {
expect(sendCommand).toHaveBeenCalled(); expect(sendCommand).toHaveBeenCalled();
}); });
it("starts relay with heartbeat tuning", async () => {
monitorWebProvider.mockResolvedValue(undefined);
const program = buildProgram();
await program.parseAsync(
[
"relay-legacy",
"--web-heartbeat",
"90",
"--heartbeat-now",
"--provider",
"web",
],
{
from: "user",
},
);
expect(logWebSelfId).toHaveBeenCalled();
expect(monitorWebProvider).toHaveBeenCalledWith(
false,
undefined,
true,
undefined,
runtime,
expect.any(AbortSignal),
{ heartbeatSeconds: 90, replyHeartbeatNow: true },
);
expect(monitorTelegramProvider).not.toHaveBeenCalled();
});
it("runs telegram relay when token set", async () => {
const program = buildProgram();
const prev = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "token123";
await program.parseAsync(["relay-legacy", "--provider", "telegram"], {
from: "user",
});
expect(monitorTelegramProvider).toHaveBeenCalledWith(
expect.objectContaining({ token: "token123" }),
);
expect(monitorWebProvider).not.toHaveBeenCalled();
process.env.TELEGRAM_BOT_TOKEN = prev;
});
it("errors when telegram provider requested without token", async () => {
const program = buildProgram();
const prev = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
await expect(
program.parseAsync(["relay-legacy", "--provider", "telegram"], {
from: "user",
}),
).rejects.toThrow();
expect(runtime.error).toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalled();
process.env.TELEGRAM_BOT_TOKEN = prev;
});
it("relay command is deprecated", async () => {
const program = buildProgram();
await expect(
program.parseAsync(["relay"], { from: "user" }),
).rejects.toThrow("exit");
expect(runtime.error).toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(monitorWebProvider).not.toHaveBeenCalled();
});
it("runs status command", async () => { it("runs status command", async () => {
const program = buildProgram(); const program = buildProgram();
await program.parseAsync(["status"], { from: "user" }); await program.parseAsync(["status"], { from: "user" });

View File

@@ -9,23 +9,10 @@ import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js"; import { startGatewayServer } from "../gateway/server.js";
import { danger, info, setVerbose } from "../globals.js"; import { danger, info, setVerbose } from "../globals.js";
import { acquireRelayLock, RelayLockError } from "../infra/relay-lock.js"; import { loginWeb, logoutWeb } from "../provider-web.js";
import { getResolvedLoggerSettings } from "../logging.js";
import {
loginWeb,
logoutWeb,
monitorWebProvider,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
type WebMonitorTuning,
} from "../provider-web.js";
import { runRpcLoop } from "../rpc/loop.js"; import { runRpcLoop } from "../rpc/loop.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import {
resolveHeartbeatSeconds,
resolveReconnectPolicy,
} from "../web/reconnect.js";
import { import {
ensureWebChatServerFromConfig, ensureWebChatServerFromConfig,
startWebChatServer, startWebChatServer,
@@ -80,20 +67,16 @@ export function buildProgram() {
"Send via your web session and print JSON result.", "Send via your web session and print JSON result.",
], ],
[ [
"clawdis relay --verbose", "clawdis gateway --port 18789",
"Auto-reply loop using your linked web session.", "Run the WebSocket Gateway locally.",
], ],
[ [
"clawdis heartbeat --verbose", "clawdis gw:status",
"Send a heartbeat ping to your active session or first allowFrom contact.", "Fetch Gateway status over WS.",
],
[
"clawdis status",
"Show web session health and recent session recipients.",
], ],
[ [
'clawdis agent --to +15555550123 --message "Run summary" --deliver', 'clawdis agent --to +15555550123 --message "Run summary" --deliver',
"Talk directly to the agent using the same session handling; optionally send the WhatsApp reply.", "Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.",
], ],
[ [
'clawdis send --provider telegram --to @mychat --message "Hi"', 'clawdis send --provider telegram --to @mychat --message "Hi"',
@@ -246,91 +229,6 @@ Examples:
await runRpcLoop({ input: process.stdin, output: process.stdout }); await runRpcLoop({ input: process.stdin, output: process.stdout });
await new Promise<never>(() => {}); await new Promise<never>(() => {});
}); });
program
.command("heartbeat")
.description("Trigger a heartbeat or manual send once (web provider only)")
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
.option(
"--session-id <id>",
"Force a session id for this heartbeat (resumes a specific Pi session)",
)
.option(
"--all",
"Send heartbeat to all active sessions (or allowFrom entries when none)",
false,
)
.option(
"--message <text>",
"Send a custom message instead of the heartbeat probe",
)
.option("--body <text>", "Alias for --message")
.option("--dry-run", "Print the resolved payload without sending", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdis heartbeat # uses web session + first allowFrom contact
clawdis heartbeat --verbose # prints detailed heartbeat logs
clawdis heartbeat --to +1555123 # override destination
clawdis heartbeat --session-id <uuid> --to +1555123 # resume a specific session
clawdis heartbeat --message "Ping"
clawdis heartbeat --all # send to every active session recipient or allowFrom entry`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const allowAll = Boolean(opts.all);
const resolution = resolveHeartbeatRecipients(cfg, {
to: opts.to,
all: allowAll,
});
if (
!opts.to &&
!allowAll &&
resolution.source === "session-ambiguous" &&
resolution.recipients.length > 1
) {
defaultRuntime.error(
danger(
`Multiple active sessions found (${resolution.recipients.join(", ")}). Pass --to <E.164> or --all to send to all.`,
),
);
defaultRuntime.exit(1);
}
const recipients = resolution.recipients;
if (!recipients || recipients.length === 0) {
defaultRuntime.error(
danger(
"No destination found. Add inbound.allowFrom numbers or pass --to <E.164>.",
),
);
defaultRuntime.exit(1);
}
const overrideBody =
(opts.message as string | undefined) ||
(opts.body as string | undefined) ||
undefined;
const dryRun = Boolean(opts.dryRun);
try {
for (const to of recipients) {
await runWebHeartbeatOnce({
to,
verbose: Boolean(opts.verbose),
runtime: defaultRuntime,
sessionId: opts.sessionId,
overrideBody,
dryRun,
});
}
} catch {
defaultRuntime.exit(1);
}
});
program program
.command("gateway") .command("gateway")
.description("Run the WebSocket Gateway (replaces relay)") .description("Run the WebSocket Gateway (replaces relay)")
@@ -505,339 +403,6 @@ Examples:
} }
}), }),
); );
program
.command("relay")
.description(
"Auto-reply to inbound messages across configured providers (web, Telegram)",
)
.option(
"--provider <auto|web|telegram|all>",
"Which providers to start: auto (default), web, telegram, or all",
)
.option(
"--web-heartbeat <seconds>",
"Heartbeat interval for web relay health logs (seconds)",
)
.option(
"--web-retries <count>",
"Max consecutive web reconnect attempts before exit (0 = unlimited)",
)
.option(
"--web-retry-initial <ms>",
"Initial reconnect backoff for web relay (ms)",
)
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
.option(
"--heartbeat-now",
"Run a heartbeat immediately when relay starts",
false,
)
.option(
"--webhook",
"Run Telegram webhook server instead of long-poll",
false,
)
.option(
"--webhook-path <path>",
"Telegram webhook path (default /telegram-webhook when webhook enabled)",
)
.option(
"--webhook-secret <secret>",
"Secret token to verify Telegram webhook requests",
)
.option("--port <port>", "Port for Telegram webhook server (default 8787)")
.option(
"--webhook-url <url>",
"Public Telegram webhook URL to register (overrides localhost autodetect)",
)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdis relay # starts WhatsApp; also Telegram if bot token set
clawdis relay --provider web # force WhatsApp-only
clawdis relay --provider telegram # Telegram-only (needs TELEGRAM_BOT_TOKEN)
clawdis relay --heartbeat-now # send immediate agent heartbeat on start (web)
clawdis relay --web-heartbeat 60 # override WhatsApp heartbeat interval
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
`,
)
.action(async (_opts) => {
defaultRuntime.error(
danger(
"`clawdis relay` is deprecated. Use the WebSocket Gateway (`clawdis gateway`) plus gw:* commands or WebChat/mac app clients.",
),
);
defaultRuntime.exit(1);
});
// relay is deprecated; gateway is the single entry point.
program
.command("relay-legacy")
.description(
"(Deprecated) legacy relay for web/telegram; use `gateway` instead",
)
.option(
"--provider <auto|web|telegram|all>",
"Which providers to start: auto (default), web, telegram, or all",
)
.option(
"--web-heartbeat <seconds>",
"Heartbeat interval for web relay health logs (seconds)",
)
.option(
"--web-retries <count>",
"Max consecutive web reconnect attempts before exit (0 = unlimited)",
)
.option(
"--web-retry-initial <ms>",
"Initial reconnect backoff for web relay (ms)",
)
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
.option(
"--heartbeat-now",
"Run a heartbeat immediately when relay starts",
false,
)
.option(
"--webhook",
"Run Telegram webhook server instead of long-poll",
false,
)
.option(
"--webhook-path <path>",
"Telegram webhook path (default /telegram-webhook when webhook enabled)",
)
.option(
"--webhook-secret <secret>",
"Secret token to verify Telegram webhook requests",
)
.option("--port <port>", "Port for Telegram webhook server (default 8787)")
.option(
"--webhook-url <url>",
"Public Telegram webhook URL to register (overrides localhost autodetect)",
)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
This command is legacy and will be removed. Prefer the Gateway.
`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
let releaseRelayLock: (() => Promise<void>) | null = null;
try {
releaseRelayLock = await acquireRelayLock();
} catch (err) {
if (err instanceof RelayLockError) {
defaultRuntime.error(danger(`Relay already running: ${err.message}`));
defaultRuntime.exit(1);
return;
}
throw err;
}
const providerOpt = (opts.provider ?? "auto").toLowerCase();
const cfg = loadConfig();
const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken;
let startWeb = false;
let startTelegram = false;
switch (providerOpt) {
case "web":
startWeb = true;
break;
case "telegram":
startTelegram = true;
break;
case "all":
startWeb = true;
startTelegram = true;
break;
default:
startWeb = true;
startTelegram = Boolean(telegramToken);
break;
}
if (startTelegram && !telegramToken) {
defaultRuntime.error(
danger(
"Telegram relay requires TELEGRAM_BOT_TOKEN or telegram.botToken in config",
),
);
defaultRuntime.exit(1);
return;
}
if (!startWeb && !startTelegram) {
defaultRuntime.error(
danger("No providers selected. Use --provider web|telegram|all."),
);
defaultRuntime.exit(1);
return;
}
const webHeartbeat =
opts.webHeartbeat !== undefined
? Number.parseInt(String(opts.webHeartbeat), 10)
: undefined;
const webRetries =
opts.webRetries !== undefined
? Number.parseInt(String(opts.webRetries), 10)
: undefined;
const webRetryInitial =
opts.webRetryInitial !== undefined
? Number.parseInt(String(opts.webRetryInitial), 10)
: undefined;
const webRetryMax =
opts.webRetryMax !== undefined
? Number.parseInt(String(opts.webRetryMax), 10)
: undefined;
const heartbeatNow = Boolean(opts.heartbeatNow);
if (
webHeartbeat !== undefined &&
(Number.isNaN(webHeartbeat) || webHeartbeat <= 0)
) {
defaultRuntime.error("--web-heartbeat must be a positive integer");
defaultRuntime.exit(1);
}
if (
webRetries !== undefined &&
(Number.isNaN(webRetries) || webRetries < 0)
) {
defaultRuntime.error("--web-retries must be >= 0");
defaultRuntime.exit(1);
}
if (
webRetryInitial !== undefined &&
(Number.isNaN(webRetryInitial) || webRetryInitial <= 0)
) {
defaultRuntime.error("--web-retry-initial must be a positive integer");
defaultRuntime.exit(1);
}
if (
webRetryMax !== undefined &&
(Number.isNaN(webRetryMax) || webRetryMax <= 0)
) {
defaultRuntime.error("--web-retry-max must be a positive integer");
defaultRuntime.exit(1);
}
if (
webRetryMax !== undefined &&
webRetryInitial !== undefined &&
webRetryMax < webRetryInitial
) {
defaultRuntime.error("--web-retry-max must be >= --web-retry-initial");
defaultRuntime.exit(1);
}
const controller = new AbortController();
const stopAll = () => controller.abort();
process.once("SIGINT", stopAll);
const runners: Array<Promise<unknown>> = [];
if (startWeb) {
const webTuning: WebMonitorTuning = {};
if (webHeartbeat !== undefined)
webTuning.heartbeatSeconds = webHeartbeat;
if (heartbeatNow) webTuning.replyHeartbeatNow = true;
const reconnect: WebMonitorTuning["reconnect"] = {};
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
if (webRetryInitial !== undefined)
reconnect.initialMs = webRetryInitial;
if (webRetryMax !== undefined) reconnect.maxMs = webRetryMax;
if (Object.keys(reconnect).length > 0) {
webTuning.reconnect = reconnect;
}
logWebSelfId(defaultRuntime, true);
const effectiveHeartbeat = resolveHeartbeatSeconds(
cfg,
webTuning.heartbeatSeconds,
);
const effectivePolicy = resolveReconnectPolicy(
cfg,
webTuning.reconnect,
);
defaultRuntime.log(
info(
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
),
);
const webchatServer = await ensureWebChatServerFromConfig();
if (webchatServer) {
defaultRuntime.log(
info(
`webchat listening on http://127.0.0.1:${webchatServer.port}/webchat/`,
),
);
}
runners.push(
monitorWebProvider(
Boolean(opts.verbose),
undefined,
true,
undefined,
defaultRuntime,
controller.signal,
webTuning,
),
);
}
if (startTelegram) {
const useWebhook = Boolean(opts.webhook);
const telegramRunner = (async () => {
const { monitorTelegramProvider } = await import(
"../telegram/monitor.js"
);
const sharedOpts = {
token: telegramToken,
runtime: defaultRuntime,
abortSignal: controller.signal,
} as const;
if (useWebhook) {
const port = opts.port
? Number.parseInt(String(opts.port), 10)
: 8787;
const path = opts.webhookPath ?? "/telegram-webhook";
return monitorTelegramProvider({
...sharedOpts,
useWebhook: true,
webhookPath: path,
webhookPort: port,
webhookSecret: opts.webhookSecret ?? cfg.telegram?.webhookSecret,
webhookUrl: opts.webhookUrl ?? cfg.telegram?.webhookUrl,
});
}
return monitorTelegramProvider(sharedOpts);
})();
runners.push(telegramRunner);
}
try {
await Promise.all(runners);
} catch (err) {
defaultRuntime.error(danger(`Relay failed: ${String(err)}`));
defaultRuntime.exit(1);
} finally {
if (releaseRelayLock) await releaseRelayLock();
}
});
// relay is the single entry point; heartbeat/Telegram helpers removed.
program program
.command("status") .command("status")
.description("Show web session health and recent session recipients") .description("Show web session health and recent session recipients")