Compare commits
1 Commits
main
...
ui/ux-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cadf7e9a50 |
@ -174,12 +174,7 @@ final class GatewayConnectionController {
|
|||||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||||
if resolvedUseTLS, stored == nil {
|
if resolvedUseTLS, stored == nil {
|
||||||
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
||||||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||||
self.appModel?.gatewayStatusText =
|
|
||||||
"TLS handshake failed for \(host):\(resolvedPort). "
|
|
||||||
+ "Remote gateways must use HTTPS/WSS."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||||
self.pendingTrustPrompt = TrustPrompt(
|
self.pendingTrustPrompt = TrustPrompt(
|
||||||
stableID: stableID,
|
stableID: stableID,
|
||||||
|
|||||||
@ -607,7 +607,7 @@ struct OnboardingWizardView: View {
|
|||||||
private var authStep: some View {
|
private var authStep: some View {
|
||||||
Group {
|
Group {
|
||||||
Section("Authentication") {
|
Section("Authentication") {
|
||||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||||
@ -724,12 +724,6 @@ struct OnboardingWizardView: View {
|
|||||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
if self.selectedMode == .remoteDomain {
|
|
||||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
|
||||||
}
|
|
||||||
self.manualConnectButton
|
self.manualConnectButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -289,17 +289,6 @@ public final class OpenClawChatViewModel {
|
|||||||
stopReason: message.stopReason)
|
stopReason: message.stopReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
|
|
||||||
message.content.map { item in
|
|
||||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
|
||||||
}.joined(separator: "\\u{001E}")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
guard !role.isEmpty else { return nil }
|
guard !role.isEmpty else { return nil }
|
||||||
@ -309,7 +298,15 @@ public final class OpenClawChatViewModel {
|
|||||||
return String(format: "%.3f", value)
|
return String(format: "%.3f", value)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
let contentFingerprint = message.content.map { item in
|
||||||
|
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
||||||
|
}.joined(separator: "\\u{001E}")
|
||||||
|
|
||||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||||
@ -318,19 +315,6 @@ public final class OpenClawChatViewModel {
|
|||||||
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func userRefreshIdentityKey(for message: OpenClawChatMessage) -> String? {
|
|
||||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
guard role == "user" else { return nil }
|
|
||||||
|
|
||||||
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
|
||||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func reconcileMessageIDs(
|
private static func reconcileMessageIDs(
|
||||||
previous: [OpenClawChatMessage],
|
previous: [OpenClawChatMessage],
|
||||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||||
@ -369,75 +353,6 @@ public final class OpenClawChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func reconcileRunRefreshMessages(
|
|
||||||
previous: [OpenClawChatMessage],
|
|
||||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
|
||||||
{
|
|
||||||
guard !previous.isEmpty else { return incoming }
|
|
||||||
guard !incoming.isEmpty else { return previous }
|
|
||||||
|
|
||||||
func countKeys(_ keys: [String]) -> [String: Int] {
|
|
||||||
keys.reduce(into: [:]) { counts, key in
|
|
||||||
counts[key, default: 0] += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
|
|
||||||
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
|
|
||||||
var remainingIncomingUserRefreshCounts = countKeys(
|
|
||||||
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
|
|
||||||
|
|
||||||
var lastMatchedPreviousIndex: Int?
|
|
||||||
for (index, message) in previous.enumerated() {
|
|
||||||
if let key = Self.messageIdentityKey(for: message),
|
|
||||||
incomingIdentityKeys.contains(key)
|
|
||||||
{
|
|
||||||
lastMatchedPreviousIndex = index
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if let userKey = Self.userRefreshIdentityKey(for: message),
|
|
||||||
let remaining = remainingIncomingUserRefreshCounts[userKey],
|
|
||||||
remaining > 0
|
|
||||||
{
|
|
||||||
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
|
|
||||||
lastMatchedPreviousIndex = index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let trailingUserMessages = (lastMatchedPreviousIndex != nil
|
|
||||||
? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!))
|
|
||||||
: ArraySlice(previous))
|
|
||||||
.filter { message in
|
|
||||||
guard message.role.lowercased() == "user" else { return false }
|
|
||||||
guard let key = Self.userRefreshIdentityKey(for: message) else { return false }
|
|
||||||
let remaining = remainingIncomingUserRefreshCounts[key] ?? 0
|
|
||||||
if remaining > 0 {
|
|
||||||
remainingIncomingUserRefreshCounts[key] = remaining - 1
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !trailingUserMessages.isEmpty else {
|
|
||||||
return reconciled
|
|
||||||
}
|
|
||||||
|
|
||||||
for message in trailingUserMessages {
|
|
||||||
guard let messageTimestamp = message.timestamp else {
|
|
||||||
reconciled.append(message)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let insertIndex = reconciled.firstIndex { existing in
|
|
||||||
guard let existingTimestamp = existing.timestamp else { return false }
|
|
||||||
return existingTimestamp > messageTimestamp
|
|
||||||
} ?? reconciled.endIndex
|
|
||||||
reconciled.insert(message, at: insertIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Self.dedupeMessages(reconciled)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||||
var result: [OpenClawChatMessage] = []
|
var result: [OpenClawChatMessage] = []
|
||||||
result.reserveCapacity(messages.count)
|
result.reserveCapacity(messages.count)
|
||||||
@ -1004,7 +919,7 @@ public final class OpenClawChatViewModel {
|
|||||||
private func refreshHistoryAfterRun() async {
|
private func refreshHistoryAfterRun() async {
|
||||||
do {
|
do {
|
||||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||||
self.messages = Self.reconcileRunRefreshMessages(
|
self.messages = Self.reconcileMessageIDs(
|
||||||
previous: self.messages,
|
previous: self.messages,
|
||||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||||
self.sessionId = payload.sessionId
|
self.sessionId = payload.sessionId
|
||||||
|
|||||||
@ -513,11 +513,8 @@ public actor GatewayChannelActor {
|
|||||||
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||||
let authToken =
|
let authToken =
|
||||||
explicitToken ??
|
explicitToken ??
|
||||||
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
(includeDeviceIdentity && explicitPassword == nil &&
|
||||||
// silently reusing an older stored device token.
|
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
|
||||||
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
|
||||||
? storedToken
|
|
||||||
: nil)
|
|
||||||
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
||||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||||
let authSource: GatewayAuthSource
|
let authSource: GatewayAuthSource
|
||||||
|
|||||||
@ -126,28 +126,6 @@ private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func sendMessageAndEmitFinal(
|
|
||||||
transport: TestChatTransport,
|
|
||||||
vm: OpenClawChatViewModel,
|
|
||||||
text: String,
|
|
||||||
sessionKey: String = "main") async throws -> String
|
|
||||||
{
|
|
||||||
await sendUserMessage(vm, text: text)
|
|
||||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
|
||||||
|
|
||||||
let runId = try #require(await transport.lastSentRunId())
|
|
||||||
transport.emit(
|
|
||||||
.chat(
|
|
||||||
OpenClawChatEventPayload(
|
|
||||||
runId: runId,
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
state: "final",
|
|
||||||
message: nil,
|
|
||||||
errorMessage: nil)))
|
|
||||||
return runId
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emitAssistantText(
|
private func emitAssistantText(
|
||||||
transport: TestChatTransport,
|
transport: TestChatTransport,
|
||||||
runId: String,
|
runId: String,
|
||||||
@ -461,141 +439,6 @@ extension TestChatTransportState {
|
|||||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func keepsOptimisticUserMessageWhenFinalRefreshReturnsOnlyAssistantHistory() async throws {
|
|
||||||
let sessionId = "sess-main"
|
|
||||||
let now = Date().timeIntervalSince1970 * 1000
|
|
||||||
let history1 = historyPayload(sessionId: sessionId)
|
|
||||||
let history2 = historyPayload(
|
|
||||||
sessionId: sessionId,
|
|
||||||
messages: [
|
|
||||||
chatTextMessage(
|
|
||||||
role: "assistant",
|
|
||||||
text: "final answer",
|
|
||||||
timestamp: now + 1),
|
|
||||||
])
|
|
||||||
|
|
||||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
|
||||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
|
||||||
try await sendMessageAndEmitFinal(
|
|
||||||
transport: transport,
|
|
||||||
vm: vm,
|
|
||||||
text: "hello from mac webchat")
|
|
||||||
|
|
||||||
try await waitUntil("assistant history refreshes without dropping user message") {
|
|
||||||
await MainActor.run {
|
|
||||||
let texts = vm.messages.map { message in
|
|
||||||
(message.role, message.content.compactMap(\.text).joined(separator: "\n"))
|
|
||||||
}
|
|
||||||
return texts.contains(where: { $0.0 == "assistant" && $0.1 == "final answer" }) &&
|
|
||||||
texts.contains(where: { $0.0 == "user" && $0.1 == "hello from mac webchat" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keepsOptimisticUserMessageWhenFinalRefreshHistoryIsTemporarilyEmpty() async throws {
|
|
||||||
let sessionId = "sess-main"
|
|
||||||
let history1 = historyPayload(sessionId: sessionId)
|
|
||||||
let history2 = historyPayload(sessionId: sessionId, messages: [])
|
|
||||||
|
|
||||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
|
||||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
|
||||||
try await sendMessageAndEmitFinal(
|
|
||||||
transport: transport,
|
|
||||||
vm: vm,
|
|
||||||
text: "hello from mac webchat")
|
|
||||||
|
|
||||||
try await waitUntil("empty refresh does not clear optimistic user message") {
|
|
||||||
await MainActor.run {
|
|
||||||
vm.messages.contains { message in
|
|
||||||
message.role == "user" &&
|
|
||||||
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func doesNotDuplicateUserMessageWhenRefreshReturnsCanonicalTimestamp() async throws {
|
|
||||||
let sessionId = "sess-main"
|
|
||||||
let now = Date().timeIntervalSince1970 * 1000
|
|
||||||
let history1 = historyPayload(sessionId: sessionId)
|
|
||||||
let history2 = historyPayload(
|
|
||||||
sessionId: sessionId,
|
|
||||||
messages: [
|
|
||||||
chatTextMessage(
|
|
||||||
role: "user",
|
|
||||||
text: "hello from mac webchat",
|
|
||||||
timestamp: now + 5_000),
|
|
||||||
chatTextMessage(
|
|
||||||
role: "assistant",
|
|
||||||
text: "final answer",
|
|
||||||
timestamp: now + 6_000),
|
|
||||||
])
|
|
||||||
|
|
||||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
|
||||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
|
||||||
try await sendMessageAndEmitFinal(
|
|
||||||
transport: transport,
|
|
||||||
vm: vm,
|
|
||||||
text: "hello from mac webchat")
|
|
||||||
|
|
||||||
try await waitUntil("canonical refresh keeps one user message") {
|
|
||||||
await MainActor.run {
|
|
||||||
let userMessages = vm.messages.filter { message in
|
|
||||||
message.role == "user" &&
|
|
||||||
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
|
|
||||||
}
|
|
||||||
let hasAssistant = vm.messages.contains { message in
|
|
||||||
message.role == "assistant" &&
|
|
||||||
message.content.compactMap(\.text).joined(separator: "\n") == "final answer"
|
|
||||||
}
|
|
||||||
return hasAssistant && userMessages.count == 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws {
|
|
||||||
let sessionId = "sess-main"
|
|
||||||
let now = Date().timeIntervalSince1970 * 1000
|
|
||||||
let history1 = historyPayload(sessionId: sessionId)
|
|
||||||
let history2 = historyPayload(
|
|
||||||
sessionId: sessionId,
|
|
||||||
messages: [
|
|
||||||
chatTextMessage(
|
|
||||||
role: "user",
|
|
||||||
text: "retry",
|
|
||||||
timestamp: now + 5_000),
|
|
||||||
chatTextMessage(
|
|
||||||
role: "assistant",
|
|
||||||
text: "first answer",
|
|
||||||
timestamp: now + 6_000),
|
|
||||||
])
|
|
||||||
|
|
||||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2])
|
|
||||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
|
||||||
try await sendMessageAndEmitFinal(
|
|
||||||
transport: transport,
|
|
||||||
vm: vm,
|
|
||||||
text: "retry")
|
|
||||||
try await sendMessageAndEmitFinal(
|
|
||||||
transport: transport,
|
|
||||||
vm: vm,
|
|
||||||
text: "retry")
|
|
||||||
|
|
||||||
try await waitUntil("repeated optimistic user message is preserved") {
|
|
||||||
await MainActor.run {
|
|
||||||
let retryMessages = vm.messages.filter { message in
|
|
||||||
message.role == "user" &&
|
|
||||||
message.content.compactMap(\.text).joined(separator: "\n") == "retry"
|
|
||||||
}
|
|
||||||
let hasAssistant = vm.messages.contains { message in
|
|
||||||
message.role == "assistant" &&
|
|
||||||
message.content.compactMap(\.text).joined(separator: "\n") == "first answer"
|
|
||||||
}
|
|
||||||
return hasAssistant && retryMessages.count == 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
||||||
let history1 = historyPayload()
|
let history1 = historyPayload()
|
||||||
let history2 = historyPayload(
|
let history2 = historyPayload(
|
||||||
|
|||||||
@ -15,7 +15,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var _state: URLSessionTask.State = .suspended
|
private var _state: URLSessionTask.State = .suspended
|
||||||
private var connectRequestId: String?
|
private var connectRequestId: String?
|
||||||
private var connectAuth: [String: Any]?
|
|
||||||
private var receivePhase = 0
|
private var receivePhase = 0
|
||||||
private var pendingReceiveHandler:
|
private var pendingReceiveHandler:
|
||||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
@ -51,18 +50,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||||||
obj["method"] as? String == "connect",
|
obj["method"] as? String == "connect",
|
||||||
let id = obj["id"] as? String
|
let id = obj["id"] as? String
|
||||||
{
|
{
|
||||||
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
|
self.lock.withLock { self.connectRequestId = id }
|
||||||
self.lock.withLock {
|
|
||||||
self.connectRequestId = id
|
|
||||||
self.connectAuth = auth
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func latestConnectAuth() -> [String: Any]? {
|
|
||||||
self.lock.withLock { self.connectAuth }
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||||
pongReceiveHandler(nil)
|
pongReceiveHandler(nil)
|
||||||
}
|
}
|
||||||
@ -178,62 +169,6 @@ private actor SeqGapProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayNodeSessionTests {
|
struct GatewayNodeSessionTests {
|
||||||
@Test
|
|
||||||
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
||||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
|
||||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
|
||||||
defer {
|
|
||||||
if let previousStateDir {
|
|
||||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
|
||||||
} else {
|
|
||||||
unsetenv("OPENCLAW_STATE_DIR")
|
|
||||||
}
|
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
|
||||||
_ = DeviceAuthStore.storeToken(
|
|
||||||
deviceId: identity.deviceId,
|
|
||||||
role: "operator",
|
|
||||||
token: "stored-device-token")
|
|
||||||
|
|
||||||
let session = FakeGatewayWebSocketSession()
|
|
||||||
let gateway = GatewayNodeSession()
|
|
||||||
let options = GatewayConnectOptions(
|
|
||||||
role: "operator",
|
|
||||||
scopes: ["operator.read"],
|
|
||||||
caps: [],
|
|
||||||
commands: [],
|
|
||||||
permissions: [:],
|
|
||||||
clientId: "openclaw-ios-test",
|
|
||||||
clientMode: "ui",
|
|
||||||
clientDisplayName: "iOS Test",
|
|
||||||
includeDeviceIdentity: true)
|
|
||||||
|
|
||||||
try await gateway.connect(
|
|
||||||
url: URL(string: "ws://example.invalid")!,
|
|
||||||
token: nil,
|
|
||||||
bootstrapToken: "fresh-bootstrap-token",
|
|
||||||
password: nil,
|
|
||||||
connectOptions: options,
|
|
||||||
sessionBox: WebSocketSessionBox(session: session),
|
|
||||||
onConnected: {},
|
|
||||||
onDisconnected: { _ in },
|
|
||||||
onInvoke: { req in
|
|
||||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
|
||||||
})
|
|
||||||
|
|
||||||
let auth = try #require(session.latestTask()?.latestConnectAuth())
|
|
||||||
#expect(auth["bootstrapToken"] as? String == "fresh-bootstrap-token")
|
|
||||||
#expect(auth["token"] == nil)
|
|
||||||
#expect(auth["deviceToken"] == nil)
|
|
||||||
|
|
||||||
await gateway.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||||
let normalized = canonicalizeCanvasHostUrl(
|
let normalized = canonicalizeCanvasHostUrl(
|
||||||
|
|||||||
@ -1,14 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
clearDeviceBootstrapTokens,
|
|
||||||
issueDeviceBootstrapToken,
|
issueDeviceBootstrapToken,
|
||||||
listDevicePairing,
|
listDevicePairing,
|
||||||
revokeDeviceBootstrapToken,
|
|
||||||
} from "openclaw/plugin-sdk/device-bootstrap";
|
} from "openclaw/plugin-sdk/device-bootstrap";
|
||||||
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||||
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
|
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
|
||||||
export {
|
export { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox";
|
||||||
resolvePreferredOpenClawTmpDir,
|
|
||||||
runPluginCommandWithTimeout,
|
|
||||||
} from "openclaw/plugin-sdk/sandbox";
|
|
||||||
export { renderQrPngBase64 } from "./qr-image.js";
|
|
||||||
|
|||||||
@ -1,359 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type {
|
|
||||||
OpenClawPluginCommandDefinition,
|
|
||||||
PluginCommandContext,
|
|
||||||
} from "openclaw/plugin-sdk/core";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
|
||||||
import type { OpenClawPluginApi } from "./api.js";
|
|
||||||
|
|
||||||
const pluginApiMocks = vi.hoisted(() => ({
|
|
||||||
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
|
|
||||||
issueDeviceBootstrapToken: vi.fn(async () => ({
|
|
||||||
token: "boot-token",
|
|
||||||
expiresAtMs: Date.now() + 10 * 60_000,
|
|
||||||
})),
|
|
||||||
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
|
|
||||||
renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="),
|
|
||||||
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./api.js", () => {
|
|
||||||
return {
|
|
||||||
approveDevicePairing: vi.fn(),
|
|
||||||
clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens,
|
|
||||||
definePluginEntry: vi.fn((entry) => entry),
|
|
||||||
issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken,
|
|
||||||
listDevicePairing: vi.fn(async () => ({ pending: [] })),
|
|
||||||
renderQrPngBase64: pluginApiMocks.renderQrPngBase64,
|
|
||||||
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
|
|
||||||
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
|
|
||||||
resolveGatewayBindUrl: vi.fn(),
|
|
||||||
resolveTailnetHostWithRunner: vi.fn(),
|
|
||||||
runPluginCommandWithTimeout: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./notify.js", () => ({
|
|
||||||
armPairNotifyOnce: vi.fn(async () => false),
|
|
||||||
formatPendingRequests: vi.fn(() => "No pending device pairing requests."),
|
|
||||||
handleNotifyCommand: vi.fn(async () => ({ text: "notify" })),
|
|
||||||
registerPairingNotifierService: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import registerDevicePair from "./index.js";
|
|
||||||
|
|
||||||
function createApi(params?: {
|
|
||||||
runtime?: OpenClawPluginApi["runtime"];
|
|
||||||
pluginConfig?: Record<string, unknown>;
|
|
||||||
registerCommand?: (command: OpenClawPluginCommandDefinition) => void;
|
|
||||||
}): OpenClawPluginApi {
|
|
||||||
return createTestPluginApi({
|
|
||||||
id: "device-pair",
|
|
||||||
name: "device-pair",
|
|
||||||
source: "test",
|
|
||||||
config: {
|
|
||||||
gateway: {
|
|
||||||
auth: {
|
|
||||||
mode: "token",
|
|
||||||
token: "gateway-token",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pluginConfig: {
|
|
||||||
publicUrl: "ws://51.79.175.165:18789",
|
|
||||||
...(params?.pluginConfig ?? {}),
|
|
||||||
},
|
|
||||||
runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"],
|
|
||||||
registerCommand: params?.registerCommand,
|
|
||||||
}) as OpenClawPluginApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerPairCommand(params?: {
|
|
||||||
runtime?: OpenClawPluginApi["runtime"];
|
|
||||||
pluginConfig?: Record<string, unknown>;
|
|
||||||
}): OpenClawPluginCommandDefinition {
|
|
||||||
let command: OpenClawPluginCommandDefinition | undefined;
|
|
||||||
registerDevicePair.register(
|
|
||||||
createApi({
|
|
||||||
...params,
|
|
||||||
registerCommand: (nextCommand) => {
|
|
||||||
command = nextCommand;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(command).toBeTruthy();
|
|
||||||
return command!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChannelRuntime(
|
|
||||||
runtimeKey: string,
|
|
||||||
sendKey: string,
|
|
||||||
sendMessage: (...args: unknown[]) => Promise<unknown>,
|
|
||||||
): OpenClawPluginApi["runtime"] {
|
|
||||||
return {
|
|
||||||
channel: {
|
|
||||||
[runtimeKey]: {
|
|
||||||
[sendKey]: sendMessage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpenClawPluginApi["runtime"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
|
|
||||||
return {
|
|
||||||
channel: "webchat",
|
|
||||||
isAuthorizedSender: true,
|
|
||||||
commandBody: "/pair qr",
|
|
||||||
args: "qr",
|
|
||||||
config: {},
|
|
||||||
requestConversationBinding: async () => ({
|
|
||||||
status: "error",
|
|
||||||
message: "unsupported",
|
|
||||||
}),
|
|
||||||
detachConversationBinding: async () => ({ removed: false }),
|
|
||||||
getCurrentConversationBinding: async () => null,
|
|
||||||
...params,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("device-pair /pair qr", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({
|
|
||||||
token: "boot-token",
|
|
||||||
expiresAtMs: Date.now() + 10 * 60_000,
|
|
||||||
});
|
|
||||||
await fs.mkdir(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an inline QR image for webchat surfaces", async () => {
|
|
||||||
const command = registerPairCommand();
|
|
||||||
const result = await command?.handler(createCommandContext({ channel: "webchat" }));
|
|
||||||
|
|
||||||
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result?.text).toContain("Scan this QR code with the OpenClaw iOS app:");
|
|
||||||
expect(result?.text).toContain("");
|
|
||||||
expect(result?.text).toContain("- Security: single-use bootstrap token");
|
|
||||||
expect(result?.text).toContain("**Important:** Run `/pair cleanup` after pairing finishes.");
|
|
||||||
expect(result?.text).toContain("If this QR code leaks, run `/pair cleanup` immediately.");
|
|
||||||
expect(result?.text).not.toContain("```");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => {
|
|
||||||
pluginApiMocks.issueDeviceBootstrapToken
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
token: "first-token",
|
|
||||||
expiresAtMs: Date.now() + 10 * 60_000,
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
token: "second-token",
|
|
||||||
expiresAtMs: Date.now() + 10 * 60_000,
|
|
||||||
});
|
|
||||||
pluginApiMocks.renderQrPngBase64.mockRejectedValueOnce(new Error("render failed"));
|
|
||||||
|
|
||||||
const command = registerPairCommand();
|
|
||||||
const result = await command?.handler(createCommandContext({ channel: "webchat" }));
|
|
||||||
|
|
||||||
expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({
|
|
||||||
token: "first-token",
|
|
||||||
});
|
|
||||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2);
|
|
||||||
expect(result?.text).toContain(
|
|
||||||
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.",
|
|
||||||
);
|
|
||||||
expect(result?.text).toContain("Pairing setup code generated.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
label: "Telegram",
|
|
||||||
runtimeKey: "telegram",
|
|
||||||
sendKey: "sendMessageTelegram",
|
|
||||||
ctx: {
|
|
||||||
channel: "telegram",
|
|
||||||
senderId: "123",
|
|
||||||
accountId: "default",
|
|
||||||
messageThreadId: 271,
|
|
||||||
},
|
|
||||||
expectedTarget: "123",
|
|
||||||
expectedOpts: {
|
|
||||||
accountId: "default",
|
|
||||||
messageThreadId: 271,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Discord",
|
|
||||||
runtimeKey: "discord",
|
|
||||||
sendKey: "sendMessageDiscord",
|
|
||||||
ctx: {
|
|
||||||
channel: "discord",
|
|
||||||
senderId: "123",
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
expectedTarget: "user:123",
|
|
||||||
expectedOpts: {
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Slack",
|
|
||||||
runtimeKey: "slack",
|
|
||||||
sendKey: "sendMessageSlack",
|
|
||||||
ctx: {
|
|
||||||
channel: "slack",
|
|
||||||
senderId: "user:U123",
|
|
||||||
accountId: "default",
|
|
||||||
messageThreadId: "1234567890.000001",
|
|
||||||
},
|
|
||||||
expectedTarget: "user:U123",
|
|
||||||
expectedOpts: {
|
|
||||||
accountId: "default",
|
|
||||||
threadTs: "1234567890.000001",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Signal",
|
|
||||||
runtimeKey: "signal",
|
|
||||||
sendKey: "sendMessageSignal",
|
|
||||||
ctx: {
|
|
||||||
channel: "signal",
|
|
||||||
senderId: "signal:+15551234567",
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
expectedTarget: "signal:+15551234567",
|
|
||||||
expectedOpts: {
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "iMessage",
|
|
||||||
runtimeKey: "imessage",
|
|
||||||
sendKey: "sendMessageIMessage",
|
|
||||||
ctx: {
|
|
||||||
channel: "imessage",
|
|
||||||
senderId: "+15551234567",
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
expectedTarget: "+15551234567",
|
|
||||||
expectedOpts: {
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "WhatsApp",
|
|
||||||
runtimeKey: "whatsapp",
|
|
||||||
sendKey: "sendMessageWhatsApp",
|
|
||||||
ctx: {
|
|
||||||
channel: "whatsapp",
|
|
||||||
senderId: "+15551234567",
|
|
||||||
accountId: "default",
|
|
||||||
},
|
|
||||||
expectedTarget: "+15551234567",
|
|
||||||
expectedOpts: {
|
|
||||||
accountId: "default",
|
|
||||||
verbose: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])("sends $label a real QR image attachment", async (testCase) => {
|
|
||||||
let sentPng = "";
|
|
||||||
const sendMessage = vi.fn().mockImplementation(async (_target, _caption, opts) => {
|
|
||||||
if (opts?.mediaUrl) {
|
|
||||||
sentPng = await fs.readFile(opts.mediaUrl, "utf8");
|
|
||||||
}
|
|
||||||
return { messageId: "1" };
|
|
||||||
});
|
|
||||||
const command = registerPairCommand({
|
|
||||||
runtime: createChannelRuntime(testCase.runtimeKey, testCase.sendKey, sendMessage),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await command?.handler(createCommandContext(testCase.ctx));
|
|
||||||
|
|
||||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
||||||
const [target, caption, opts] = sendMessage.mock.calls[0] as [
|
|
||||||
string,
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
mediaUrl?: string;
|
|
||||||
mediaLocalRoots?: string[];
|
|
||||||
accountId?: string;
|
|
||||||
} & Record<string, unknown>,
|
|
||||||
];
|
|
||||||
expect(target).toBe(testCase.expectedTarget);
|
|
||||||
expect(caption).toContain("Scan this QR code with the OpenClaw iOS app:");
|
|
||||||
expect(caption).toContain("IMPORTANT: After pairing finishes, run /pair cleanup.");
|
|
||||||
expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately.");
|
|
||||||
expect(opts.mediaUrl).toMatch(/pair-qr\.png$/);
|
|
||||||
expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]);
|
|
||||||
expect(opts).toMatchObject(testCase.expectedOpts);
|
|
||||||
expect(sentPng).toBe("fakepng");
|
|
||||||
await expect(fs.access(opts.mediaUrl!)).rejects.toBeTruthy();
|
|
||||||
expect(result?.text).toContain("QR code sent above.");
|
|
||||||
expect(result?.text).toContain("IMPORTANT: Run /pair cleanup after pairing finishes.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reissues the bootstrap token after QR delivery failure before falling back", async () => {
|
|
||||||
pluginApiMocks.issueDeviceBootstrapToken
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
token: "first-token",
|
|
||||||
expiresAtMs: Date.now() + 10 * 60_000,
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
token: "second-token",
|
|
||||||
expiresAtMs: Date.now() + 10 * 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendMessage = vi.fn().mockRejectedValue(new Error("upload failed"));
|
|
||||||
const command = registerPairCommand({
|
|
||||||
runtime: createChannelRuntime("discord", "sendMessageDiscord", sendMessage),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await command?.handler(
|
|
||||||
createCommandContext({
|
|
||||||
channel: "discord",
|
|
||||||
senderId: "123",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({
|
|
||||||
token: "first-token",
|
|
||||||
});
|
|
||||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2);
|
|
||||||
expect(result?.text).toContain("Pairing setup code generated.");
|
|
||||||
expect(result?.text).toContain("If this code leaks or you are done, run /pair cleanup");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the setup code instead of ASCII when the channel cannot send media", async () => {
|
|
||||||
const command = registerPairCommand();
|
|
||||||
const result = await command?.handler(
|
|
||||||
createCommandContext({
|
|
||||||
channel: "msteams",
|
|
||||||
senderId: "8:orgid:123",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result?.text).toContain("QR image delivery is not available on this channel");
|
|
||||||
expect(result?.text).toContain("Setup code:");
|
|
||||||
expect(result?.text).toContain("IMPORTANT: After pairing finishes, run /pair cleanup.");
|
|
||||||
expect(result?.text).not.toContain("```");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports invalidating unused setup codes", async () => {
|
|
||||||
const command = registerPairCommand();
|
|
||||||
const result = await command?.handler(
|
|
||||||
createCommandContext({
|
|
||||||
args: "cleanup",
|
|
||||||
commandBody: "/pair cleanup",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(pluginApiMocks.clearDeviceBootstrapTokens).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toEqual({ text: "Invalidated 2 unused setup codes." });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,18 +1,13 @@
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import qrcode from "qrcode-terminal";
|
||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
clearDeviceBootstrapTokens,
|
|
||||||
definePluginEntry,
|
definePluginEntry,
|
||||||
issueDeviceBootstrapToken,
|
issueDeviceBootstrapToken,
|
||||||
listDevicePairing,
|
listDevicePairing,
|
||||||
renderQrPngBase64,
|
|
||||||
revokeDeviceBootstrapToken,
|
|
||||||
resolveGatewayBindUrl,
|
resolveGatewayBindUrl,
|
||||||
resolvePreferredOpenClawTmpDir,
|
|
||||||
resolveTailnetHostWithRunner,
|
|
||||||
runPluginCommandWithTimeout,
|
runPluginCommandWithTimeout,
|
||||||
|
resolveTailnetHostWithRunner,
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
@ -22,24 +17,12 @@ import {
|
|||||||
registerPairingNotifierService,
|
registerPairingNotifierService,
|
||||||
} from "./notify.js";
|
} from "./notify.js";
|
||||||
|
|
||||||
async function renderQrDataUrl(data: string): Promise<string> {
|
function renderQrAscii(data: string): Promise<string> {
|
||||||
const pngBase64 = await renderQrPngBase64(data);
|
return new Promise((resolve) => {
|
||||||
return `data:image/png;base64,${pngBase64}`;
|
qrcode.generate(data, { small: true }, (output: string) => {
|
||||||
}
|
resolve(output);
|
||||||
|
});
|
||||||
async function writeQrPngTempFile(data: string): Promise<string> {
|
});
|
||||||
const pngBase64 = await renderQrPngBase64(data);
|
|
||||||
const tmpRoot = resolvePreferredOpenClawTmpDir();
|
|
||||||
const qrDir = await mkdtemp(path.join(tmpRoot, "device-pair-qr-"));
|
|
||||||
const filePath = path.join(qrDir, "pair-qr.png");
|
|
||||||
await writeFile(filePath, Buffer.from(pngBase64, "base64"));
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDurationMinutes(expiresAtMs: number): string {
|
|
||||||
const msRemaining = Math.max(0, expiresAtMs - Date.now());
|
|
||||||
const minutes = Math.max(1, Math.ceil(msRemaining / 60_000));
|
|
||||||
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_PORT = 18789;
|
const DEFAULT_GATEWAY_PORT = 18789;
|
||||||
@ -51,7 +34,6 @@ type DevicePairPluginConfig = {
|
|||||||
type SetupPayload = {
|
type SetupPayload = {
|
||||||
url: string;
|
url: string;
|
||||||
bootstrapToken: string;
|
bootstrapToken: string;
|
||||||
expiresAtMs: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolveUrlResult = {
|
type ResolveUrlResult = {
|
||||||
@ -65,85 +47,6 @@ type ResolveAuthLabelResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type QrCommandContext = {
|
|
||||||
channel: string;
|
|
||||||
senderId?: string;
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
accountId?: string;
|
|
||||||
messageThreadId?: string | number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QrChannelSender = {
|
|
||||||
resolveSend: (api: OpenClawPluginApi) => QrSendFn | undefined;
|
|
||||||
createOpts: (params: {
|
|
||||||
ctx: QrCommandContext;
|
|
||||||
qrFilePath: string;
|
|
||||||
mediaLocalRoots: string[];
|
|
||||||
accountId?: string;
|
|
||||||
}) => Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QrSendFn = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
||||||
|
|
||||||
function coerceQrSend(send: unknown): QrSendFn | undefined {
|
|
||||||
return typeof send === "function" ? (send as QrSendFn) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
|
|
||||||
telegram: {
|
|
||||||
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.telegram?.sendMessageTelegram),
|
|
||||||
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
|
|
||||||
mediaUrl: qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
...(typeof ctx.messageThreadId === "number" ? { messageThreadId: ctx.messageThreadId } : {}),
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
discord: {
|
|
||||||
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.discord?.sendMessageDiscord),
|
|
||||||
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
|
|
||||||
mediaUrl: qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
slack: {
|
|
||||||
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.slack?.sendMessageSlack),
|
|
||||||
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
|
|
||||||
mediaUrl: qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
...(ctx.messageThreadId != null ? { threadTs: String(ctx.messageThreadId) } : {}),
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
signal: {
|
|
||||||
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.signal?.sendMessageSignal),
|
|
||||||
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
|
|
||||||
mediaUrl: qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
imessage: {
|
|
||||||
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.imessage?.sendMessageIMessage),
|
|
||||||
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
|
|
||||||
mediaUrl: qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
whatsapp: {
|
|
||||||
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.whatsapp?.sendMessageWhatsApp),
|
|
||||||
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
|
|
||||||
verbose: false,
|
|
||||||
mediaUrl: qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
|
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
|
||||||
const candidate = raw.trim();
|
const candidate = raw.trim();
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@ -396,172 +299,33 @@ function encodeSetupCode(payload: SetupPayload): string {
|
|||||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPairingFlowLines(stepTwo: string): string[] {
|
|
||||||
return [
|
|
||||||
"1) Open the iOS app → Settings → Gateway",
|
|
||||||
`2) ${stepTwo}`,
|
|
||||||
"3) Back here, run /pair approve",
|
|
||||||
"4) If this code leaks or you are done, run /pair cleanup",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSecurityNoticeLines(params: {
|
|
||||||
kind: "setup code" | "QR code";
|
|
||||||
expiresAtMs: number;
|
|
||||||
markdown?: boolean;
|
|
||||||
}): string[] {
|
|
||||||
const cleanupCommand = params.markdown ? "`/pair cleanup`" : "/pair cleanup";
|
|
||||||
const securityPrefix = params.markdown ? "- " : "";
|
|
||||||
const importantLine = params.markdown
|
|
||||||
? `**Important:** Run ${cleanupCommand} after pairing finishes.`
|
|
||||||
: `IMPORTANT: After pairing finishes, run ${cleanupCommand}.`;
|
|
||||||
return [
|
|
||||||
`${securityPrefix}Security: single-use bootstrap token`,
|
|
||||||
`${securityPrefix}Expires: ${formatDurationMinutes(params.expiresAtMs)}`,
|
|
||||||
"",
|
|
||||||
importantLine,
|
|
||||||
`If this ${params.kind} leaks, run ${cleanupCommand} immediately.`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQrFollowUpLines(autoNotifyArmed: boolean): string[] {
|
|
||||||
return autoNotifyArmed
|
|
||||||
? [
|
|
||||||
"After scanning, wait here for the pairing request ping.",
|
|
||||||
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
|
|
||||||
"If the ping does not arrive, run `/pair approve latest` manually.",
|
|
||||||
]
|
|
||||||
: ["After scanning, run `/pair approve` to complete pairing."];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSetupReply(payload: SetupPayload, authLabel: string): string {
|
function formatSetupReply(payload: SetupPayload, authLabel: string): string {
|
||||||
const setupCode = encodeSetupCode(payload);
|
const setupCode = encodeSetupCode(payload);
|
||||||
return [
|
return [
|
||||||
"Pairing setup code generated.",
|
"Pairing setup code generated.",
|
||||||
"",
|
"",
|
||||||
...buildPairingFlowLines("Paste the setup code below and tap Connect"),
|
"1) Open the iOS app → Settings → Gateway",
|
||||||
|
"2) Paste the setup code below and tap Connect",
|
||||||
|
"3) Back here, run /pair approve",
|
||||||
"",
|
"",
|
||||||
"Setup code:",
|
"Setup code:",
|
||||||
setupCode,
|
setupCode,
|
||||||
"",
|
"",
|
||||||
`Gateway: ${payload.url}`,
|
`Gateway: ${payload.url}`,
|
||||||
`Auth: ${authLabel}`,
|
`Auth: ${authLabel}`,
|
||||||
...buildSecurityNoticeLines({
|
|
||||||
kind: "setup code",
|
|
||||||
expiresAtMs: payload.expiresAtMs,
|
|
||||||
}),
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSetupInstructions(expiresAtMs: number): string {
|
function formatSetupInstructions(): string {
|
||||||
return [
|
return [
|
||||||
"Pairing setup code generated.",
|
"Pairing setup code generated.",
|
||||||
"",
|
"",
|
||||||
...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
|
"1) Open the iOS app → Settings → Gateway",
|
||||||
"",
|
"2) Paste the setup code from my next message and tap Connect",
|
||||||
...buildSecurityNoticeLines({
|
"3) Back here, run /pair approve",
|
||||||
kind: "setup code",
|
|
||||||
expiresAtMs,
|
|
||||||
}),
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQrInfoLines(params: {
|
|
||||||
payload: SetupPayload;
|
|
||||||
authLabel: string;
|
|
||||||
autoNotifyArmed: boolean;
|
|
||||||
expiresAtMs: number;
|
|
||||||
}): string[] {
|
|
||||||
return [
|
|
||||||
`Gateway: ${params.payload.url}`,
|
|
||||||
`Auth: ${params.authLabel}`,
|
|
||||||
...buildSecurityNoticeLines({
|
|
||||||
kind: "QR code",
|
|
||||||
expiresAtMs: params.expiresAtMs,
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
...buildQrFollowUpLines(params.autoNotifyArmed),
|
|
||||||
"",
|
|
||||||
"If your camera still won’t lock on, run `/pair` for a pasteable setup code.",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatQrInfoMarkdown(params: {
|
|
||||||
payload: SetupPayload;
|
|
||||||
authLabel: string;
|
|
||||||
autoNotifyArmed: boolean;
|
|
||||||
expiresAtMs: number;
|
|
||||||
}): string {
|
|
||||||
return [
|
|
||||||
`- Gateway: ${params.payload.url}`,
|
|
||||||
`- Auth: ${params.authLabel}`,
|
|
||||||
...buildSecurityNoticeLines({
|
|
||||||
kind: "QR code",
|
|
||||||
expiresAtMs: params.expiresAtMs,
|
|
||||||
markdown: true,
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
...buildQrFollowUpLines(params.autoNotifyArmed),
|
|
||||||
"",
|
|
||||||
"If your camera still won’t lock on, run `/pair` for a pasteable setup code.",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function canSendQrPngToChannel(channel: string): boolean {
|
|
||||||
return channel in QR_CHANNEL_SENDERS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveQrReplyTarget(ctx: QrCommandContext): string {
|
|
||||||
if (ctx.channel === "discord") {
|
|
||||||
const senderId = ctx.senderId?.trim() ?? "";
|
|
||||||
if (senderId) {
|
|
||||||
return senderId.startsWith("user:") || senderId.startsWith("channel:")
|
|
||||||
? senderId
|
|
||||||
: `user:${senderId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function issueSetupPayload(url: string): Promise<SetupPayload> {
|
|
||||||
const issuedBootstrap = await issueDeviceBootstrapToken();
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
bootstrapToken: issuedBootstrap.token,
|
|
||||||
expiresAtMs: issuedBootstrap.expiresAtMs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendQrPngToSupportedChannel(params: {
|
|
||||||
api: OpenClawPluginApi;
|
|
||||||
ctx: QrCommandContext;
|
|
||||||
target: string;
|
|
||||||
caption: string;
|
|
||||||
qrFilePath: string;
|
|
||||||
}): Promise<boolean> {
|
|
||||||
const mediaLocalRoots = [path.dirname(params.qrFilePath)];
|
|
||||||
const accountId = params.ctx.accountId?.trim() || undefined;
|
|
||||||
const sender = QR_CHANNEL_SENDERS[params.ctx.channel];
|
|
||||||
if (!sender) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const send = sender.resolveSend(params.api);
|
|
||||||
if (!send) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await send(
|
|
||||||
params.target,
|
|
||||||
params.caption,
|
|
||||||
sender.createOpts({
|
|
||||||
ctx: params.ctx,
|
|
||||||
qrFilePath: params.qrFilePath,
|
|
||||||
mediaLocalRoots,
|
|
||||||
accountId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePluginEntry({
|
export default definePluginEntry({
|
||||||
id: "device-pair",
|
id: "device-pair",
|
||||||
name: "Device Pair",
|
name: "Device Pair",
|
||||||
@ -636,16 +400,6 @@ export default definePluginEntry({
|
|||||||
return { text: `✅ Paired ${label}${platformLabel}.` };
|
return { text: `✅ Paired ${label}${platformLabel}.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "cleanup" || action === "clear" || action === "revoke") {
|
|
||||||
const cleared = await clearDeviceBootstrapTokens();
|
|
||||||
return {
|
|
||||||
text:
|
|
||||||
cleared.removed > 0
|
|
||||||
? `Invalidated ${cleared.removed} unused setup code${cleared.removed === 1 ? "" : "s"}.`
|
|
||||||
: "No unused setup codes were active.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authLabelResult = resolveAuthLabel(api.config);
|
const authLabelResult = resolveAuthLabel(api.config);
|
||||||
if (authLabelResult.error) {
|
if (authLabelResult.error) {
|
||||||
return { text: `Error: ${authLabelResult.error}` };
|
return { text: `Error: ${authLabelResult.error}` };
|
||||||
@ -655,11 +409,19 @@ export default definePluginEntry({
|
|||||||
if (!urlResult.url) {
|
if (!urlResult.url) {
|
||||||
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
|
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
|
||||||
}
|
}
|
||||||
const authLabel = authLabelResult.label ?? "auth";
|
|
||||||
|
const payload: SetupPayload = {
|
||||||
|
url: urlResult.url,
|
||||||
|
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
||||||
|
};
|
||||||
|
|
||||||
if (action === "qr") {
|
if (action === "qr") {
|
||||||
|
const setupCode = encodeSetupCode(payload);
|
||||||
|
const qrAscii = await renderQrAscii(setupCode);
|
||||||
|
const authLabel = authLabelResult.label ?? "auth";
|
||||||
|
|
||||||
const channel = ctx.channel;
|
const channel = ctx.channel;
|
||||||
const target = resolveQrReplyTarget(ctx);
|
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||||
let autoNotifyArmed = false;
|
let autoNotifyArmed = false;
|
||||||
|
|
||||||
if (channel === "telegram" && target) {
|
if (channel === "telegram" && target) {
|
||||||
@ -674,99 +436,82 @@ export default definePluginEntry({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = await issueSetupPayload(urlResult.url);
|
if (channel === "telegram" && target) {
|
||||||
let setupCode = encodeSetupCode(payload);
|
|
||||||
|
|
||||||
const infoLines = buildQrInfoLines({
|
|
||||||
payload,
|
|
||||||
authLabel,
|
|
||||||
autoNotifyArmed,
|
|
||||||
expiresAtMs: payload.expiresAtMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (target && canSendQrPngToChannel(channel)) {
|
|
||||||
let qrFilePath: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
qrFilePath = await writeQrPngTempFile(setupCode);
|
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
|
||||||
const sent = await sendQrPngToSupportedChannel({
|
if (send) {
|
||||||
api,
|
await send(
|
||||||
ctx,
|
target,
|
||||||
target,
|
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
|
||||||
caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
|
"\n",
|
||||||
"\n",
|
),
|
||||||
),
|
{
|
||||||
qrFilePath,
|
...(ctx.messageThreadId != null
|
||||||
});
|
? { messageThreadId: ctx.messageThreadId }
|
||||||
if (sent) {
|
: {}),
|
||||||
|
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
text:
|
text: [
|
||||||
`QR code sent above.\n` +
|
`Gateway: ${payload.url}`,
|
||||||
`Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` +
|
`Auth: ${authLabel}`,
|
||||||
"IMPORTANT: Run /pair cleanup after pairing finishes.",
|
"",
|
||||||
|
autoNotifyArmed
|
||||||
|
? "After scanning, wait here for the pairing request ping."
|
||||||
|
: "After scanning, come back here and run `/pair approve` to complete pairing.",
|
||||||
|
...(autoNotifyArmed
|
||||||
|
? [
|
||||||
|
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
|
||||||
|
"If the ping does not arrive, run `/pair approve latest` manually.",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
].join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn?.(
|
api.logger.warn?.(
|
||||||
`device-pair: QR image send failed channel=${channel}, falling back (${String(
|
`device-pair: telegram QR send failed, falling back (${String(
|
||||||
(err as Error)?.message ?? err,
|
(err as Error)?.message ?? err,
|
||||||
)})`,
|
)})`,
|
||||||
);
|
);
|
||||||
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
|
|
||||||
payload = await issueSetupPayload(urlResult.url);
|
|
||||||
setupCode = encodeSetupCode(payload);
|
|
||||||
} finally {
|
|
||||||
if (qrFilePath) {
|
|
||||||
await rm(path.dirname(qrFilePath), { recursive: true, force: true }).catch(
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render based on channel capability
|
||||||
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
|
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
|
||||||
if (channel === "webchat") {
|
const infoLines = [
|
||||||
let qrDataUrl: string;
|
`Gateway: ${payload.url}`,
|
||||||
try {
|
`Auth: ${authLabel}`,
|
||||||
qrDataUrl = await renderQrDataUrl(setupCode);
|
"",
|
||||||
} catch (err) {
|
autoNotifyArmed
|
||||||
api.logger.warn?.(
|
? "After scanning, wait here for the pairing request ping."
|
||||||
`device-pair: webchat QR render failed, falling back (${String(
|
: "After scanning, run `/pair approve` to complete pairing.",
|
||||||
(err as Error)?.message ?? err,
|
...(autoNotifyArmed
|
||||||
)})`,
|
? [
|
||||||
);
|
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
|
||||||
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
|
"If the ping does not arrive, run `/pair approve latest` manually.",
|
||||||
payload = await issueSetupPayload(urlResult.url);
|
]
|
||||||
return {
|
: []),
|
||||||
text:
|
];
|
||||||
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" +
|
|
||||||
formatSetupReply(payload, authLabel),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text: [
|
|
||||||
"Scan this QR code with the OpenClaw iOS app:",
|
|
||||||
"",
|
|
||||||
formatQrInfoMarkdown({
|
|
||||||
payload,
|
|
||||||
authLabel,
|
|
||||||
autoNotifyArmed,
|
|
||||||
expiresAtMs: payload.expiresAtMs,
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
``,
|
|
||||||
].join("\n"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// WebUI + CLI/TUI: ASCII QR
|
||||||
return {
|
return {
|
||||||
text:
|
text: [
|
||||||
"QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" +
|
"Scan this QR code with the OpenClaw iOS app:",
|
||||||
formatSetupReply(payload, authLabel),
|
"",
|
||||||
|
"```",
|
||||||
|
qrAscii,
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
...infoLines,
|
||||||
|
].join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = ctx.channel;
|
const channel = ctx.channel;
|
||||||
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||||
const payload = await issueSetupPayload(urlResult.url);
|
const authLabel = authLabelResult.label ?? "auth";
|
||||||
|
|
||||||
if (channel === "telegram" && target) {
|
if (channel === "telegram" && target) {
|
||||||
try {
|
try {
|
||||||
@ -785,10 +530,8 @@ export default definePluginEntry({
|
|||||||
)})`,
|
)})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await send(target, formatSetupInstructions(payload.expiresAtMs), {
|
await send(target, formatSetupInstructions(), {
|
||||||
...(typeof ctx.messageThreadId === "number"
|
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
|
||||||
? { messageThreadId: ctx.messageThreadId }
|
|
||||||
: {}),
|
|
||||||
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
||||||
});
|
});
|
||||||
api.logger.info?.(
|
api.logger.info?.(
|
||||||
@ -805,6 +548,7 @@ export default definePluginEntry({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: formatSetupReply(payload, authLabel),
|
text: formatSetupReply(payload, authLabel),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
|
|||||||
type NotifySubscription = {
|
type NotifySubscription = {
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
mode: "persistent" | "once";
|
mode: "persistent" | "once";
|
||||||
addedAtMs: number;
|
addedAtMs: number;
|
||||||
};
|
};
|
||||||
@ -101,11 +101,9 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile {
|
|||||||
? record.accountId.trim()
|
? record.accountId.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const messageThreadId =
|
const messageThreadId =
|
||||||
typeof record.messageThreadId === "string"
|
typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
|
||||||
? record.messageThreadId.trim() || undefined
|
? Math.trunc(record.messageThreadId)
|
||||||
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
|
: undefined;
|
||||||
? Math.trunc(record.messageThreadId)
|
|
||||||
: undefined;
|
|
||||||
const mode = record.mode === "once" ? "once" : "persistent";
|
const mode = record.mode === "once" ? "once" : "persistent";
|
||||||
const addedAtMs =
|
const addedAtMs =
|
||||||
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
|
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
|
||||||
@ -152,7 +150,7 @@ async function writeNotifyState(filePath: string, state: NotifyStateFile): Promi
|
|||||||
function notifySubscriberKey(subscriber: {
|
function notifySubscriberKey(subscriber: {
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
}): string {
|
}): string {
|
||||||
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
|
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
|
||||||
}
|
}
|
||||||
@ -160,7 +158,7 @@ function notifySubscriberKey(subscriber: {
|
|||||||
type NotifyTarget = {
|
type NotifyTarget = {
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveNotifyTarget(ctx: {
|
function resolveNotifyTarget(ctx: {
|
||||||
@ -168,7 +166,7 @@ function resolveNotifyTarget(ctx: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
}): NotifyTarget | null {
|
}): NotifyTarget | null {
|
||||||
const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||||
if (!to) {
|
if (!to) {
|
||||||
@ -263,7 +261,7 @@ async function notifySubscriber(params: {
|
|||||||
try {
|
try {
|
||||||
await send(params.subscriber.to, params.text, {
|
await send(params.subscriber.to, params.text, {
|
||||||
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
|
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
|
||||||
...(typeof params.subscriber.messageThreadId === "number"
|
...(params.subscriber.messageThreadId != null
|
||||||
? { messageThreadId: params.subscriber.messageThreadId }
|
? { messageThreadId: params.subscriber.messageThreadId }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
@ -349,7 +347,7 @@ export async function armPairNotifyOnce(params: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
};
|
};
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
if (params.ctx.channel !== "telegram") {
|
if (params.ctx.channel !== "telegram") {
|
||||||
@ -383,7 +381,7 @@ export async function handleNotifyCommand(params: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
};
|
};
|
||||||
action: string;
|
action: string;
|
||||||
}): Promise<{ text: string }> {
|
}): Promise<{ text: string }> {
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime";
|
|
||||||
import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js";
|
|
||||||
import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js";
|
|
||||||
|
|
||||||
type QRCodeConstructor = new (
|
|
||||||
typeNumber: number,
|
|
||||||
errorCorrectLevel: unknown,
|
|
||||||
) => {
|
|
||||||
addData: (data: string) => void;
|
|
||||||
make: () => void;
|
|
||||||
getModuleCount: () => number;
|
|
||||||
isDark: (row: number, col: number) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const QRCode = QRCodeModule as QRCodeConstructor;
|
|
||||||
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
|
|
||||||
|
|
||||||
function createQrMatrix(input: string) {
|
|
||||||
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
|
|
||||||
qr.addData(input);
|
|
||||||
qr.make();
|
|
||||||
return qr;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderQrPngBase64(
|
|
||||||
input: string,
|
|
||||||
opts: { scale?: number; marginModules?: number } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
const { scale = 6, marginModules = 4 } = opts;
|
|
||||||
const qr = createQrMatrix(input);
|
|
||||||
const modules = qr.getModuleCount();
|
|
||||||
const size = (modules + marginModules * 2) * scale;
|
|
||||||
|
|
||||||
const buf = Buffer.alloc(size * size * 4, 255);
|
|
||||||
for (let row = 0; row < modules; row += 1) {
|
|
||||||
for (let col = 0; col < modules; col += 1) {
|
|
||||||
if (!qr.isDark(row, col)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const startX = (col + marginModules) * scale;
|
|
||||||
const startY = (row + marginModules) * scale;
|
|
||||||
for (let y = 0; y < scale; y += 1) {
|
|
||||||
const pixelY = startY + y;
|
|
||||||
for (let x = 0; x < scale; x += 1) {
|
|
||||||
const pixelX = startX + x;
|
|
||||||
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const png = encodePngRgba(buf, size, size);
|
|
||||||
return png.toString("base64");
|
|
||||||
}
|
|
||||||
@ -51,18 +51,16 @@ type FeishuThreadBindingsState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
|
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
|
||||||
let state: FeishuThreadBindingsState | undefined;
|
const state = resolveGlobalSingleton<FeishuThreadBindingsState>(
|
||||||
|
FEISHU_THREAD_BINDINGS_STATE_KEY,
|
||||||
|
() => ({
|
||||||
|
managersByAccountId: new Map(),
|
||||||
|
bindingsByAccountConversation: new Map(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
function getState(): FeishuThreadBindingsState {
|
const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId;
|
||||||
state ??= resolveGlobalSingleton<FeishuThreadBindingsState>(
|
const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation;
|
||||||
FEISHU_THREAD_BINDINGS_STATE_KEY,
|
|
||||||
() => ({
|
|
||||||
managersByAccountId: new Map(),
|
|
||||||
bindingsByAccountConversation: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
|
function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
|
||||||
return `${params.accountId}:${params.conversationId}`;
|
return `${params.accountId}:${params.conversationId}`;
|
||||||
@ -121,7 +119,7 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
}): FeishuThreadBindingManager {
|
}): FeishuThreadBindingManager {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const existing = getState().managersByAccountId.get(accountId);
|
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@ -140,11 +138,9 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
const manager: FeishuThreadBindingManager = {
|
const manager: FeishuThreadBindingManager = {
|
||||||
accountId,
|
accountId,
|
||||||
getByConversationId: (conversationId) =>
|
getByConversationId: (conversationId) =>
|
||||||
getState().bindingsByAccountConversation.get(
|
BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })),
|
||||||
resolveBindingKey({ accountId, conversationId }),
|
|
||||||
),
|
|
||||||
listBySessionKey: (targetSessionKey) =>
|
listBySessionKey: (targetSessionKey) =>
|
||||||
[...getState().bindingsByAccountConversation.values()].filter(
|
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
||||||
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
|
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
|
||||||
),
|
),
|
||||||
bindConversation: ({
|
bindConversation: ({
|
||||||
@ -188,7 +184,7 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
boundAt: now,
|
boundAt: now,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
};
|
};
|
||||||
getState().bindingsByAccountConversation.set(
|
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
|
||||||
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
|
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
|
||||||
record,
|
record,
|
||||||
);
|
);
|
||||||
@ -196,30 +192,30 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
},
|
},
|
||||||
touchConversation: (conversationId, at = Date.now()) => {
|
touchConversation: (conversationId, at = Date.now()) => {
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const existingRecord = getState().bindingsByAccountConversation.get(key);
|
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
||||||
if (!existingRecord) {
|
if (!existingRecord) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const updated = { ...existingRecord, lastActivityAt: at };
|
const updated = { ...existingRecord, lastActivityAt: at };
|
||||||
getState().bindingsByAccountConversation.set(key, updated);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
unbindConversation: (conversationId) => {
|
unbindConversation: (conversationId) => {
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const existingRecord = getState().bindingsByAccountConversation.get(key);
|
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
||||||
if (!existingRecord) {
|
if (!existingRecord) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
getState().bindingsByAccountConversation.delete(key);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
||||||
return existingRecord;
|
return existingRecord;
|
||||||
},
|
},
|
||||||
unbindBySessionKey: (targetSessionKey) => {
|
unbindBySessionKey: (targetSessionKey) => {
|
||||||
const removed: FeishuThreadBindingRecord[] = [];
|
const removed: FeishuThreadBindingRecord[] = [];
|
||||||
for (const record of [...getState().bindingsByAccountConversation.values()]) {
|
for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) {
|
||||||
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
|
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
getState().bindingsByAccountConversation.delete(
|
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(
|
||||||
resolveBindingKey({ accountId, conversationId: record.conversationId }),
|
resolveBindingKey({ accountId, conversationId: record.conversationId }),
|
||||||
);
|
);
|
||||||
removed.push(record);
|
removed.push(record);
|
||||||
@ -227,12 +223,12 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
return removed;
|
return removed;
|
||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
for (const key of [...getState().bindingsByAccountConversation.keys()]) {
|
for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) {
|
||||||
if (key.startsWith(`${accountId}:`)) {
|
if (key.startsWith(`${accountId}:`)) {
|
||||||
getState().bindingsByAccountConversation.delete(key);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getState().managersByAccountId.delete(accountId);
|
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
||||||
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
|
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -294,22 +290,22 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
getState().managersByAccountId.set(accountId, manager);
|
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeishuThreadBindingManager(
|
export function getFeishuThreadBindingManager(
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): FeishuThreadBindingManager | null {
|
): FeishuThreadBindingManager | null {
|
||||||
return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
|
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __testing = {
|
export const __testing = {
|
||||||
resetFeishuThreadBindingsForTests() {
|
resetFeishuThreadBindingsForTests() {
|
||||||
for (const manager of getState().managersByAccountId.values()) {
|
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
||||||
manager.stop();
|
manager.stop();
|
||||||
}
|
}
|
||||||
getState().managersByAccountId.clear();
|
MANAGERS_BY_ACCOUNT_ID.clear();
|
||||||
getState().bindingsByAccountConversation.clear();
|
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,12 +15,7 @@ const MAX_ENTRIES = 5000;
|
|||||||
*/
|
*/
|
||||||
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
|
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
|
||||||
|
|
||||||
let threadParticipation: Map<string, number> | undefined;
|
const threadParticipation = resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
|
||||||
|
|
||||||
function getThreadParticipation(): Map<string, number> {
|
|
||||||
threadParticipation ??= resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
|
|
||||||
return threadParticipation;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeKey(accountId: string, channelId: string, threadTs: string): string {
|
function makeKey(accountId: string, channelId: string, threadTs: string): string {
|
||||||
return `${accountId}:${channelId}:${threadTs}`;
|
return `${accountId}:${channelId}:${threadTs}`;
|
||||||
@ -28,17 +23,17 @@ function makeKey(accountId: string, channelId: string, threadTs: string): string
|
|||||||
|
|
||||||
function evictExpired(): void {
|
function evictExpired(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, timestamp] of getThreadParticipation()) {
|
for (const [key, timestamp] of threadParticipation) {
|
||||||
if (now - timestamp > TTL_MS) {
|
if (now - timestamp > TTL_MS) {
|
||||||
getThreadParticipation().delete(key);
|
threadParticipation.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evictOldest(): void {
|
function evictOldest(): void {
|
||||||
const oldest = getThreadParticipation().keys().next().value;
|
const oldest = threadParticipation.keys().next().value;
|
||||||
if (oldest) {
|
if (oldest) {
|
||||||
getThreadParticipation().delete(oldest);
|
threadParticipation.delete(oldest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +45,6 @@ export function recordSlackThreadParticipation(
|
|||||||
if (!accountId || !channelId || !threadTs) {
|
if (!accountId || !channelId || !threadTs) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const threadParticipation = getThreadParticipation();
|
|
||||||
if (threadParticipation.size >= MAX_ENTRIES) {
|
if (threadParticipation.size >= MAX_ENTRIES) {
|
||||||
evictExpired();
|
evictExpired();
|
||||||
}
|
}
|
||||||
@ -69,7 +63,6 @@ export function hasSlackThreadParticipation(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const key = makeKey(accountId, channelId, threadTs);
|
const key = makeKey(accountId, channelId, threadTs);
|
||||||
const threadParticipation = getThreadParticipation();
|
|
||||||
const timestamp = threadParticipation.get(key);
|
const timestamp = threadParticipation.get(key);
|
||||||
if (timestamp == null) {
|
if (timestamp == null) {
|
||||||
return false;
|
return false;
|
||||||
@ -82,5 +75,5 @@ export function hasSlackThreadParticipation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearSlackThreadParticipationCache(): void {
|
export function clearSlackThreadParticipationCache(): void {
|
||||||
getThreadParticipation().clear();
|
threadParticipation.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,17 +28,11 @@ type TelegramSendMessageDraft = (
|
|||||||
*/
|
*/
|
||||||
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
|
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
|
||||||
|
|
||||||
let draftStreamState: { nextDraftId: number } | undefined;
|
const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
|
||||||
|
nextDraftId: 0,
|
||||||
function getDraftStreamState(): { nextDraftId: number } {
|
}));
|
||||||
draftStreamState ??= resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
|
|
||||||
nextDraftId: 0,
|
|
||||||
}));
|
|
||||||
return draftStreamState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allocateTelegramDraftId(): number {
|
function allocateTelegramDraftId(): number {
|
||||||
const draftStreamState = getDraftStreamState();
|
|
||||||
draftStreamState.nextDraftId =
|
draftStreamState.nextDraftId =
|
||||||
draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1;
|
draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1;
|
||||||
return draftStreamState.nextDraftId;
|
return draftStreamState.nextDraftId;
|
||||||
@ -460,6 +454,6 @@ export function createTelegramDraftStream(params: {
|
|||||||
|
|
||||||
export const __testing = {
|
export const __testing = {
|
||||||
resetTelegramDraftStreamForTests() {
|
resetTelegramDraftStreamForTests() {
|
||||||
getDraftStreamState().nextDraftId = 0;
|
draftStreamState.nextDraftId = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -103,34 +103,17 @@ function escapeRegex(str: string): string {
|
|||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||||
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
||||||
|
const FILE_REFERENCE_PATTERN = new RegExp(
|
||||||
|
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
const ORPHANED_TLD_PATTERN = new RegExp(
|
||||||
|
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
|
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
|
||||||
let fileReferencePattern: RegExp | undefined;
|
|
||||||
let orphanedTldPattern: RegExp | undefined;
|
|
||||||
|
|
||||||
function getFileReferencePattern(): RegExp {
|
|
||||||
if (fileReferencePattern) {
|
|
||||||
return fileReferencePattern;
|
|
||||||
}
|
|
||||||
const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
|
||||||
fileReferencePattern = new RegExp(
|
|
||||||
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${fileExtensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
|
||||||
"gi",
|
|
||||||
);
|
|
||||||
return fileReferencePattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrphanedTldPattern(): RegExp {
|
|
||||||
if (orphanedTldPattern) {
|
|
||||||
return orphanedTldPattern;
|
|
||||||
}
|
|
||||||
const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
|
||||||
orphanedTldPattern = new RegExp(
|
|
||||||
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${fileExtensionsPattern}))(?=[^a-zA-Z0-9/]|$)`,
|
|
||||||
"g",
|
|
||||||
);
|
|
||||||
return orphanedTldPattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
|
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
|
||||||
if (filename.startsWith("//")) {
|
if (filename.startsWith("//")) {
|
||||||
@ -151,8 +134,8 @@ function wrapSegmentFileRefs(
|
|||||||
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
|
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
const wrappedStandalone = text.replace(getFileReferencePattern(), wrapStandaloneFileRef);
|
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
|
||||||
return wrappedStandalone.replace(getOrphanedTldPattern(), (match, prefix: string, tld: string) =>
|
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
|
||||||
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
|
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,7 @@ type CacheEntry = {
|
|||||||
*/
|
*/
|
||||||
const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages");
|
const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages");
|
||||||
|
|
||||||
let sentMessages: Map<string, CacheEntry> | undefined;
|
const sentMessages = resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY);
|
||||||
|
|
||||||
function getSentMessages(): Map<string, CacheEntry> {
|
|
||||||
sentMessages ??= resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY);
|
|
||||||
return sentMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChatKey(chatId: number | string): string {
|
function getChatKey(chatId: number | string): string {
|
||||||
return String(chatId);
|
return String(chatId);
|
||||||
@ -42,7 +37,6 @@ function cleanupExpired(entry: CacheEntry): void {
|
|||||||
*/
|
*/
|
||||||
export function recordSentMessage(chatId: number | string, messageId: number): void {
|
export function recordSentMessage(chatId: number | string, messageId: number): void {
|
||||||
const key = getChatKey(chatId);
|
const key = getChatKey(chatId);
|
||||||
const sentMessages = getSentMessages();
|
|
||||||
let entry = sentMessages.get(key);
|
let entry = sentMessages.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { timestamps: new Map() };
|
entry = { timestamps: new Map() };
|
||||||
@ -60,7 +54,7 @@ export function recordSentMessage(chatId: number | string, messageId: number): v
|
|||||||
*/
|
*/
|
||||||
export function wasSentByBot(chatId: number | string, messageId: number): boolean {
|
export function wasSentByBot(chatId: number | string, messageId: number): boolean {
|
||||||
const key = getChatKey(chatId);
|
const key = getChatKey(chatId);
|
||||||
const entry = getSentMessages().get(key);
|
const entry = sentMessages.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -73,5 +67,5 @@ export function wasSentByBot(chatId: number | string, messageId: number): boolea
|
|||||||
* Clear all cached entries (for testing).
|
* Clear all cached entries (for testing).
|
||||||
*/
|
*/
|
||||||
export function clearSentMessageCache(): void {
|
export function clearSentMessageCache(): void {
|
||||||
getSentMessages().clear();
|
sentMessages.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,19 +77,17 @@ type TelegramThreadBindingsState = {
|
|||||||
*/
|
*/
|
||||||
const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState");
|
const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState");
|
||||||
|
|
||||||
let threadBindingsState: TelegramThreadBindingsState | undefined;
|
const threadBindingsState = resolveGlobalSingleton<TelegramThreadBindingsState>(
|
||||||
|
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
|
||||||
function getThreadBindingsState(): TelegramThreadBindingsState {
|
() => ({
|
||||||
threadBindingsState ??= resolveGlobalSingleton<TelegramThreadBindingsState>(
|
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
|
||||||
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
|
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
|
||||||
() => ({
|
persistQueueByAccountId: new Map<string, Promise<void>>(),
|
||||||
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
|
}),
|
||||||
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
|
);
|
||||||
persistQueueByAccountId: new Map<string, Promise<void>>(),
|
const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId;
|
||||||
}),
|
const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation;
|
||||||
);
|
const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId;
|
||||||
return threadBindingsState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDurationMs(raw: unknown, fallback: number): number {
|
function normalizeDurationMs(raw: unknown, fallback: number): number {
|
||||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
@ -170,7 +168,7 @@ function fromSessionBindingInput(params: {
|
|||||||
}): TelegramThreadBindingRecord {
|
}): TelegramThreadBindingRecord {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const metadata = params.input.metadata ?? {};
|
const metadata = params.input.metadata ?? {};
|
||||||
const existing = getThreadBindingsState().bindingsByAccountConversation.get(
|
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(
|
||||||
resolveBindingKey({
|
resolveBindingKey({
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
conversationId: params.input.conversationId,
|
conversationId: params.input.conversationId,
|
||||||
@ -312,7 +310,7 @@ async function persistBindingsToDisk(params: {
|
|||||||
version: STORE_VERSION,
|
version: STORE_VERSION,
|
||||||
bindings:
|
bindings:
|
||||||
params.bindings ??
|
params.bindings ??
|
||||||
[...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
|
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
||||||
(entry) => entry.accountId === params.accountId,
|
(entry) => entry.accountId === params.accountId,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@ -324,7 +322,7 @@ async function persistBindingsToDisk(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
|
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
|
||||||
return [...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
|
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
||||||
(entry) => entry.accountId === accountId,
|
(entry) => entry.accountId === accountId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -337,17 +335,16 @@ function enqueuePersistBindings(params: {
|
|||||||
if (!params.persist) {
|
if (!params.persist) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
const previous =
|
const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve();
|
||||||
getThreadBindingsState().persistQueueByAccountId.get(params.accountId) ?? Promise.resolve();
|
|
||||||
const next = previous
|
const next = previous
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await persistBindingsToDisk(params);
|
await persistBindingsToDisk(params);
|
||||||
});
|
});
|
||||||
getThreadBindingsState().persistQueueByAccountId.set(params.accountId, next);
|
PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next);
|
||||||
void next.finally(() => {
|
void next.finally(() => {
|
||||||
if (getThreadBindingsState().persistQueueByAccountId.get(params.accountId) === next) {
|
if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) {
|
||||||
getThreadBindingsState().persistQueueByAccountId.delete(params.accountId);
|
PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@ -415,7 +412,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
} = {},
|
} = {},
|
||||||
): TelegramThreadBindingManager {
|
): TelegramThreadBindingManager {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const existing = getThreadBindingsState().managersByAccountId.get(accountId);
|
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@ -433,7 +430,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
accountId,
|
accountId,
|
||||||
conversationId: entry.conversationId,
|
conversationId: entry.conversationId,
|
||||||
});
|
});
|
||||||
getThreadBindingsState().bindingsByAccountConversation.set(key, {
|
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, {
|
||||||
...entry,
|
...entry,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
@ -451,7 +448,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return getThreadBindingsState().bindingsByAccountConversation.get(
|
return BINDINGS_BY_ACCOUNT_CONVERSATION.get(
|
||||||
resolveBindingKey({
|
resolveBindingKey({
|
||||||
accountId,
|
accountId,
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -474,7 +471,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const existing = getThreadBindingsState().bindingsByAccountConversation.get(key);
|
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -482,7 +479,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
...existing,
|
...existing,
|
||||||
lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
|
lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
|
||||||
};
|
};
|
||||||
getThreadBindingsState().bindingsByAccountConversation.set(key, nextRecord);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord);
|
||||||
persistBindingsSafely({
|
persistBindingsSafely({
|
||||||
accountId,
|
accountId,
|
||||||
persist: manager.shouldPersistMutations(),
|
persist: manager.shouldPersistMutations(),
|
||||||
@ -497,11 +494,11 @@ export function createTelegramThreadBindingManager(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const removed = getThreadBindingsState().bindingsByAccountConversation.get(key) ?? null;
|
const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null;
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
getThreadBindingsState().bindingsByAccountConversation.delete(key);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
||||||
persistBindingsSafely({
|
persistBindingsSafely({
|
||||||
accountId,
|
accountId,
|
||||||
persist: manager.shouldPersistMutations(),
|
persist: manager.shouldPersistMutations(),
|
||||||
@ -524,7 +521,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
accountId,
|
accountId,
|
||||||
conversationId: entry.conversationId,
|
conversationId: entry.conversationId,
|
||||||
});
|
});
|
||||||
getThreadBindingsState().bindingsByAccountConversation.delete(key);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
||||||
removed.push(entry);
|
removed.push(entry);
|
||||||
}
|
}
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
@ -543,9 +540,9 @@ export function createTelegramThreadBindingManager(
|
|||||||
sweepTimer = null;
|
sweepTimer = null;
|
||||||
}
|
}
|
||||||
unregisterSessionBindingAdapter({ channel: "telegram", accountId });
|
unregisterSessionBindingAdapter({ channel: "telegram", accountId });
|
||||||
const existingManager = getThreadBindingsState().managersByAccountId.get(accountId);
|
const existingManager = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||||
if (existingManager === manager) {
|
if (existingManager === manager) {
|
||||||
getThreadBindingsState().managersByAccountId.delete(accountId);
|
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -577,7 +574,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
getThreadBindingsState().bindingsByAccountConversation.set(
|
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
|
||||||
resolveBindingKey({ accountId, conversationId }),
|
resolveBindingKey({ accountId, conversationId }),
|
||||||
record,
|
record,
|
||||||
);
|
);
|
||||||
@ -717,14 +714,14 @@ export function createTelegramThreadBindingManager(
|
|||||||
sweepTimer.unref?.();
|
sweepTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
getThreadBindingsState().managersByAccountId.set(accountId, manager);
|
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTelegramThreadBindingManager(
|
export function getTelegramThreadBindingManager(
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): TelegramThreadBindingManager | null {
|
): TelegramThreadBindingManager | null {
|
||||||
return getThreadBindingsState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
|
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTelegramBindingsBySessionKey(params: {
|
function updateTelegramBindingsBySessionKey(params: {
|
||||||
@ -744,7 +741,7 @@ function updateTelegramBindingsBySessionKey(params: {
|
|||||||
conversationId: entry.conversationId,
|
conversationId: entry.conversationId,
|
||||||
});
|
});
|
||||||
const next = params.update(entry, now);
|
const next = params.update(entry, now);
|
||||||
getThreadBindingsState().bindingsByAccountConversation.set(key, next);
|
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, next);
|
||||||
updated.push(next);
|
updated.push(next);
|
||||||
}
|
}
|
||||||
if (updated.length > 0) {
|
if (updated.length > 0) {
|
||||||
@ -802,12 +799,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: {
|
|||||||
|
|
||||||
export const __testing = {
|
export const __testing = {
|
||||||
async resetTelegramThreadBindingsForTests() {
|
async resetTelegramThreadBindingsForTests() {
|
||||||
for (const manager of getThreadBindingsState().managersByAccountId.values()) {
|
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
||||||
manager.stop();
|
manager.stop();
|
||||||
}
|
}
|
||||||
await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values());
|
await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values());
|
||||||
getThreadBindingsState().persistQueueByAccountId.clear();
|
PERSIST_QUEUE_BY_ACCOUNT_ID.clear();
|
||||||
getThreadBindingsState().managersByAccountId.clear();
|
MANAGERS_BY_ACCOUNT_ID.clear();
|
||||||
getThreadBindingsState().bindingsByAccountConversation.clear();
|
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import fsSync from "node:fs";
|
import fsSync from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import type { MockBaileysSocket } from "../../../test/mocks/baileys.js";
|
import type { MockBaileysSocket } from "../../../test/mocks/baileys.js";
|
||||||
import { createMockBaileys } from "../../../test/mocks/baileys.js";
|
import { createMockBaileys } from "../../../test/mocks/baileys.js";
|
||||||
@ -33,21 +32,6 @@ export function resetLoadConfigMock() {
|
|||||||
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
|
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) {
|
|
||||||
if (!store) {
|
|
||||||
const agentId = (opts?.agentId?.trim() || "main").toLowerCase();
|
|
||||||
return path.join(
|
|
||||||
process.env.HOME ?? "/tmp",
|
|
||||||
".openclaw",
|
|
||||||
"agents",
|
|
||||||
agentId,
|
|
||||||
"sessions",
|
|
||||||
"sessions.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return path.resolve(store.replaceAll("{agentId}", opts?.agentId?.trim() || "main"));
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
const mockModule = Object.create(null) as Record<string, unknown>;
|
const mockModule = Object.create(null) as Record<string, unknown>;
|
||||||
@ -108,10 +92,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
value:
|
value: actual.resolveStorePath,
|
||||||
typeof actual.resolveStorePath === "function"
|
|
||||||
? actual.resolveStorePath
|
|
||||||
: resolveStorePathFallback,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return mockModule;
|
return mockModule;
|
||||||
|
|||||||
@ -43,10 +43,7 @@ export const handlePluginCommand: CommandHandler = async (
|
|||||||
to: command.to,
|
to: command.to,
|
||||||
accountId: params.ctx.AccountId ?? undefined,
|
accountId: params.ctx.AccountId ?? undefined,
|
||||||
messageThreadId:
|
messageThreadId:
|
||||||
typeof params.ctx.MessageThreadId === "string" ||
|
typeof params.ctx.MessageThreadId === "number" ? params.ctx.MessageThreadId : undefined,
|
||||||
typeof params.ctx.MessageThreadId === "number"
|
|
||||||
? params.ctx.MessageThreadId
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,10 +3,8 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||||
import {
|
import {
|
||||||
clearDeviceBootstrapTokens,
|
|
||||||
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
|
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
|
||||||
issueDeviceBootstrapToken,
|
issueDeviceBootstrapToken,
|
||||||
revokeDeviceBootstrapToken,
|
|
||||||
verifyDeviceBootstrapToken,
|
verifyDeviceBootstrapToken,
|
||||||
} from "./device-bootstrap.js";
|
} from "./device-bootstrap.js";
|
||||||
|
|
||||||
@ -17,22 +15,6 @@ function resolveBootstrapPath(baseDir: string): string {
|
|||||||
return path.join(baseDir, "devices", "bootstrap.json");
|
return path.join(baseDir, "devices", "bootstrap.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyBootstrapToken(
|
|
||||||
baseDir: string,
|
|
||||||
token: string,
|
|
||||||
overrides: Partial<Parameters<typeof verifyDeviceBootstrapToken>[0]> = {},
|
|
||||||
) {
|
|
||||||
return await verifyDeviceBootstrapToken({
|
|
||||||
token,
|
|
||||||
deviceId: "device-123",
|
|
||||||
publicKey: "public-key-123",
|
|
||||||
role: "operator.admin",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
baseDir,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
await tempDirs.cleanup();
|
await tempDirs.cleanup();
|
||||||
@ -65,85 +47,43 @@ describe("device bootstrap tokens", () => {
|
|||||||
const baseDir = await createTempDir();
|
const baseDir = await createTempDir();
|
||||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
await expect(
|
||||||
|
verifyDeviceBootstrapToken({
|
||||||
|
token: issued.token,
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator.admin",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
|
await expect(
|
||||||
ok: false,
|
verifyDeviceBootstrapToken({
|
||||||
reason: "bootstrap_token_invalid",
|
token: issued.token,
|
||||||
});
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator.admin",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
|
|
||||||
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears outstanding bootstrap tokens on demand", async () => {
|
|
||||||
const baseDir = await createTempDir();
|
|
||||||
const first = await issueDeviceBootstrapToken({ baseDir });
|
|
||||||
const second = await issueDeviceBootstrapToken({ baseDir });
|
|
||||||
|
|
||||||
await expect(clearDeviceBootstrapTokens({ baseDir })).resolves.toEqual({ removed: 2 });
|
|
||||||
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "bootstrap_token_invalid",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "bootstrap_token_invalid",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("revokes a specific bootstrap token", async () => {
|
|
||||||
const baseDir = await createTempDir();
|
|
||||||
const first = await issueDeviceBootstrapToken({ baseDir });
|
|
||||||
const second = await issueDeviceBootstrapToken({ baseDir });
|
|
||||||
|
|
||||||
await expect(revokeDeviceBootstrapToken({ baseDir, token: first.token })).resolves.toEqual({
|
|
||||||
removed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "bootstrap_token_invalid",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("consumes bootstrap tokens by the persisted map key", async () => {
|
|
||||||
const baseDir = await createTempDir();
|
|
||||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
||||||
const issuedAtMs = Date.now();
|
|
||||||
const bootstrapPath = path.join(baseDir, "devices", "bootstrap.json");
|
|
||||||
await fs.writeFile(
|
|
||||||
bootstrapPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
"legacy-key": {
|
|
||||||
token: issued.token,
|
|
||||||
ts: issuedAtMs,
|
|
||||||
issuedAtMs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
await expect(fs.readFile(bootstrapPath, "utf8")).resolves.toBe("{}");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the token when required verification fields are blank", async () => {
|
it("keeps the token when required verification fields are blank", async () => {
|
||||||
const baseDir = await createTempDir();
|
const baseDir = await createTempDir();
|
||||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
verifyBootstrapToken(baseDir, issued.token, {
|
verifyDeviceBootstrapToken({
|
||||||
|
token: issued.token,
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
role: " ",
|
role: " ",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
|
|
||||||
@ -155,9 +95,16 @@ describe("device bootstrap tokens", () => {
|
|||||||
const baseDir = await createTempDir();
|
const baseDir = await createTempDir();
|
||||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({
|
await expect(
|
||||||
ok: true,
|
verifyDeviceBootstrapToken({
|
||||||
});
|
token: ` ${issued.token} `,
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator.admin",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
||||||
});
|
});
|
||||||
@ -166,10 +113,16 @@ describe("device bootstrap tokens", () => {
|
|||||||
const baseDir = await createTempDir();
|
const baseDir = await createTempDir();
|
||||||
await issueDeviceBootstrapToken({ baseDir });
|
await issueDeviceBootstrapToken({ baseDir });
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({
|
await expect(
|
||||||
ok: false,
|
verifyDeviceBootstrapToken({
|
||||||
reason: "bootstrap_token_invalid",
|
token: " ",
|
||||||
});
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator.admin",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
verifyDeviceBootstrapToken({
|
verifyDeviceBootstrapToken({
|
||||||
@ -226,11 +179,26 @@ describe("device bootstrap tokens", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
|
await expect(
|
||||||
|
verifyDeviceBootstrapToken({
|
||||||
|
token: "legacyToken",
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator.admin",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
|
await expect(
|
||||||
ok: false,
|
verifyDeviceBootstrapToken({
|
||||||
reason: "bootstrap_token_invalid",
|
token: "expiredToken",
|
||||||
});
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator.admin",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -79,41 +79,6 @@ export async function issueDeviceBootstrapToken(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearDeviceBootstrapTokens(
|
|
||||||
params: {
|
|
||||||
baseDir?: string;
|
|
||||||
} = {},
|
|
||||||
): Promise<{ removed: number }> {
|
|
||||||
return await withLock(async () => {
|
|
||||||
const state = await loadState(params.baseDir);
|
|
||||||
const removed = Object.keys(state).length;
|
|
||||||
await persistState({}, params.baseDir);
|
|
||||||
return { removed };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeDeviceBootstrapToken(params: {
|
|
||||||
token: string;
|
|
||||||
baseDir?: string;
|
|
||||||
}): Promise<{ removed: boolean }> {
|
|
||||||
return await withLock(async () => {
|
|
||||||
const providedToken = params.token.trim();
|
|
||||||
if (!providedToken) {
|
|
||||||
return { removed: false };
|
|
||||||
}
|
|
||||||
const state = await loadState(params.baseDir);
|
|
||||||
const found = Object.entries(state).find(([, candidate]) =>
|
|
||||||
verifyPairingToken(providedToken, candidate.token),
|
|
||||||
);
|
|
||||||
if (!found) {
|
|
||||||
return { removed: false };
|
|
||||||
}
|
|
||||||
delete state[found[0]];
|
|
||||||
await persistState(state, params.baseDir);
|
|
||||||
return { removed: true };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyDeviceBootstrapToken(params: {
|
export async function verifyDeviceBootstrapToken(params: {
|
||||||
token: string;
|
token: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@ -128,13 +93,12 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||||||
if (!providedToken) {
|
if (!providedToken) {
|
||||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||||
}
|
}
|
||||||
const found = Object.entries(state).find(([, candidate]) =>
|
const entry = Object.values(state).find((candidate) =>
|
||||||
verifyPairingToken(providedToken, candidate.token),
|
verifyPairingToken(providedToken, candidate.token),
|
||||||
);
|
);
|
||||||
if (!found) {
|
if (!entry) {
|
||||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||||
}
|
}
|
||||||
const [tokenKey] = found;
|
|
||||||
|
|
||||||
const deviceId = params.deviceId.trim();
|
const deviceId = params.deviceId.trim();
|
||||||
const publicKey = params.publicKey.trim();
|
const publicKey = params.publicKey.trim();
|
||||||
@ -145,7 +109,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||||||
|
|
||||||
// Bootstrap setup codes are single-use. Consume the record before returning
|
// Bootstrap setup codes are single-use. Consume the record before returning
|
||||||
// success so the same token cannot be replayed to mutate a pending request.
|
// success so the same token cannot be replayed to mutate a pending request.
|
||||||
delete state[tokenKey];
|
delete state[entry.token];
|
||||||
await persistState(state, params.baseDir);
|
await persistState(state, params.baseDir);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
// Shared bootstrap/pairing helpers for plugins that provision remote devices.
|
// Shared bootstrap/pairing helpers for plugins that provision remote devices.
|
||||||
|
|
||||||
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
||||||
export {
|
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||||
clearDeviceBootstrapTokens,
|
|
||||||
issueDeviceBootstrapToken,
|
|
||||||
revokeDeviceBootstrapToken,
|
|
||||||
} from "../infra/device-bootstrap.js";
|
|
||||||
|
|||||||
@ -322,7 +322,7 @@ function resolveBindingConversationFromCommand(params: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
}): {
|
}): {
|
||||||
channel: string;
|
channel: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@ -963,7 +963,7 @@ export type PluginCommandContext = {
|
|||||||
/** Account id for multi-account channels */
|
/** Account id for multi-account channels */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
/** Thread/topic id if available */
|
/** Thread/topic id if available */
|
||||||
messageThreadId?: string | number;
|
messageThreadId?: number;
|
||||||
requestConversationBinding: (
|
requestConversationBinding: (
|
||||||
params?: PluginConversationBindingRequestParams,
|
params?: PluginConversationBindingRequestParams,
|
||||||
) => Promise<PluginConversationBindingRequestResult>;
|
) => Promise<PluginConversationBindingRequestResult>;
|
||||||
|
|||||||
@ -3840,15 +3840,173 @@
|
|||||||
/* Bottom grid (event log + log tail) */
|
/* Bottom grid (event log + log tail) */
|
||||||
.ov-bottom-grid {
|
.ov-bottom-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expandable log panels */
|
||||||
|
.ov-event-log,
|
||||||
|
.ov-log-tail {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-expandable-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-expandable-toggle::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-expandable-toggle::marker {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-expandable-toggle:hover {
|
||||||
|
background: var(--card-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-expandable-toggle .nav-item__icon {
|
||||||
|
color: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-expandable-toggle::after {
|
||||||
|
content: "";
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.35;
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > .ov-expandable-toggle::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-count-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-muted);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event log entries */
|
||||||
|
.ov-event-log-list {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-event-log-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-event-log-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-event-log-entry:hover {
|
||||||
|
background: var(--card-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-event-log-ts {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-event-log-name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-event-log-payload {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gateway log tail */
|
||||||
|
.ov-log-tail-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-log-refresh {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--duration-fast) var(--ease-out),
|
||||||
|
background var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-log-refresh:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ov-bottom-grid {
|
.ov-bottom-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ov-event-log-list,
|
||||||
|
.ov-log-tail-content {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.ov-access-grid {
|
.ov-access-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export function renderOverviewEventLog(props: OverviewEventLogProps) {
|
|||||||
const visible = props.events.slice(0, 20);
|
const visible = props.events.slice(0, 20);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<details class="card ov-event-log">
|
<details class="card ov-event-log" open>
|
||||||
<summary class="ov-expandable-toggle">
|
<summary class="ov-expandable-toggle">
|
||||||
<span class="nav-item__icon">${icons.radio}</span>
|
<span class="nav-item__icon">${icons.radio}</span>
|
||||||
${t("overview.eventLog.title")}
|
${t("overview.eventLog.title")}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export function renderOverviewLogTail(props: OverviewLogTailProps) {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<details class="card ov-log-tail">
|
<details class="card ov-log-tail" open>
|
||||||
<summary class="ov-expandable-toggle">
|
<summary class="ov-expandable-toggle">
|
||||||
<span class="nav-item__icon">${icons.scrollText}</span>
|
<span class="nav-item__icon">${icons.scrollText}</span>
|
||||||
${t("overview.logTail.title")}
|
${t("overview.logTail.title")}
|
||||||
|
|||||||
@ -395,7 +395,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
|
|
||||||
<div class="ov-section-divider"></div>
|
<div class="ov-section-divider"></div>
|
||||||
|
|
||||||
<div class="ov-bottom-grid" style="margin-top: 18px;">
|
<div class="ov-bottom-grid">
|
||||||
${renderOverviewEventLog({
|
${renderOverviewEventLog({
|
||||||
events: props.eventLog,
|
events: props.eventLog,
|
||||||
})}
|
})}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user