CLI: remove relay/heartbeat legacy commands
This commit is contained in:
@@ -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"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user