Compare commits
9 Commits
fix/inboun
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd372836e | ||
|
|
ce6a48195a | ||
|
|
8a05c05596 | ||
|
|
43513cd1df | ||
|
|
5bb5d7dab4 | ||
|
|
9fb78453e0 | ||
|
|
d78e13f545 | ||
|
|
6b4c24c2e5 | ||
|
|
598f1826d8 |
@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
|
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
|
||||||
- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna.
|
- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna.
|
||||||
- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
|
- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
|
||||||
|
- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
@ -191,6 +192,9 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
|
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
|
||||||
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
|
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
|
||||||
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
|
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
|
||||||
|
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
|
||||||
|
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
|
||||||
|
- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
|
|||||||
@ -174,7 +174,12 @@ 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 { return }
|
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||||
|
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") {
|
||||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
SecureField("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,6 +724,12 @@ 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,6 +289,17 @@ 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 }
|
||||||
@ -298,15 +309,7 @@ public final class OpenClawChatViewModel {
|
|||||||
return String(format: "%.3f", value)
|
return String(format: "%.3f", value)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let contentFingerprint = message.content.map { item in
|
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
||||||
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 {
|
||||||
@ -315,6 +318,19 @@ 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]
|
||||||
@ -353,6 +369,75 @@ 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)
|
||||||
@ -919,7 +1004,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.reconcileMessageIDs(
|
self.messages = Self.reconcileRunRefreshMessages(
|
||||||
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,8 +513,11 @@ public actor GatewayChannelActor {
|
|||||||
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||||
let authToken =
|
let authToken =
|
||||||
explicitToken ??
|
explicitToken ??
|
||||||
(includeDeviceIdentity && explicitPassword == nil &&
|
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
||||||
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
|
// silently reusing an older stored device token.
|
||||||
|
(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,6 +126,28 @@ 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,
|
||||||
@ -439,6 +461,141 @@ 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,6 +15,7 @@ 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)?
|
||||||
@ -50,10 +51,18 @@ 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
|
||||||
{
|
{
|
||||||
self.lock.withLock { self.connectRequestId = id }
|
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@ -169,6 +178,62 @@ 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,8 +1,14 @@
|
|||||||
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 { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox";
|
export {
|
||||||
|
resolvePreferredOpenClawTmpDir,
|
||||||
|
runPluginCommandWithTimeout,
|
||||||
|
} from "openclaw/plugin-sdk/sandbox";
|
||||||
|
export { renderQrPngBase64 } from "./qr-image.js";
|
||||||
|
|||||||
359
extensions/device-pair/index.test.ts
Normal file
359
extensions/device-pair/index.test.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
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,13 +1,18 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import qrcode from "qrcode-terminal";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
|
clearDeviceBootstrapTokens,
|
||||||
definePluginEntry,
|
definePluginEntry,
|
||||||
issueDeviceBootstrapToken,
|
issueDeviceBootstrapToken,
|
||||||
listDevicePairing,
|
listDevicePairing,
|
||||||
|
renderQrPngBase64,
|
||||||
|
revokeDeviceBootstrapToken,
|
||||||
resolveGatewayBindUrl,
|
resolveGatewayBindUrl,
|
||||||
runPluginCommandWithTimeout,
|
resolvePreferredOpenClawTmpDir,
|
||||||
resolveTailnetHostWithRunner,
|
resolveTailnetHostWithRunner,
|
||||||
|
runPluginCommandWithTimeout,
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
@ -17,12 +22,24 @@ import {
|
|||||||
registerPairingNotifierService,
|
registerPairingNotifierService,
|
||||||
} from "./notify.js";
|
} from "./notify.js";
|
||||||
|
|
||||||
function renderQrAscii(data: string): Promise<string> {
|
async function renderQrDataUrl(data: string): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
const pngBase64 = await renderQrPngBase64(data);
|
||||||
qrcode.generate(data, { small: true }, (output: string) => {
|
return `data:image/png;base64,${pngBase64}`;
|
||||||
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;
|
||||||
@ -34,6 +51,7 @@ type DevicePairPluginConfig = {
|
|||||||
type SetupPayload = {
|
type SetupPayload = {
|
||||||
url: string;
|
url: string;
|
||||||
bootstrapToken: string;
|
bootstrapToken: string;
|
||||||
|
expiresAtMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolveUrlResult = {
|
type ResolveUrlResult = {
|
||||||
@ -47,6 +65,85 @@ 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) {
|
||||||
@ -299,33 +396,172 @@ 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.",
|
||||||
"",
|
"",
|
||||||
"1) Open the iOS app → Settings → Gateway",
|
...buildPairingFlowLines("Paste the setup code below and tap Connect"),
|
||||||
"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(): string {
|
function formatSetupInstructions(expiresAtMs: number): string {
|
||||||
return [
|
return [
|
||||||
"Pairing setup code generated.",
|
"Pairing setup code generated.",
|
||||||
"",
|
"",
|
||||||
"1) Open the iOS app → Settings → Gateway",
|
...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
|
||||||
"2) Paste the setup code from my next message and tap Connect",
|
"",
|
||||||
"3) Back here, run /pair approve",
|
...buildSecurityNoticeLines({
|
||||||
|
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",
|
||||||
@ -400,6 +636,16 @@ 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}` };
|
||||||
@ -409,19 +655,11 @@ 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 = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
const target = resolveQrReplyTarget(ctx);
|
||||||
let autoNotifyArmed = false;
|
let autoNotifyArmed = false;
|
||||||
|
|
||||||
if (channel === "telegram" && target) {
|
if (channel === "telegram" && target) {
|
||||||
@ -436,82 +674,99 @@ export default definePluginEntry({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel === "telegram" && target) {
|
let payload = await issueSetupPayload(urlResult.url);
|
||||||
|
let setupCode = encodeSetupCode(payload);
|
||||||
|
|
||||||
|
const infoLines = buildQrInfoLines({
|
||||||
|
payload,
|
||||||
|
authLabel,
|
||||||
|
autoNotifyArmed,
|
||||||
|
expiresAtMs: payload.expiresAtMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (target && canSendQrPngToChannel(channel)) {
|
||||||
|
let qrFilePath: string | undefined;
|
||||||
try {
|
try {
|
||||||
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
|
qrFilePath = await writeQrPngTempFile(setupCode);
|
||||||
if (send) {
|
const sent = await sendQrPngToSupportedChannel({
|
||||||
await send(
|
api,
|
||||||
target,
|
ctx,
|
||||||
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
|
target,
|
||||||
"\n",
|
caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
|
||||||
),
|
"\n",
|
||||||
{
|
),
|
||||||
...(ctx.messageThreadId != null
|
qrFilePath,
|
||||||
? { messageThreadId: ctx.messageThreadId }
|
});
|
||||||
: {}),
|
if (sent) {
|
||||||
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
text: [
|
text:
|
||||||
`Gateway: ${payload.url}`,
|
`QR code sent above.\n` +
|
||||||
`Auth: ${authLabel}`,
|
`Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` +
|
||||||
"",
|
"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: telegram QR send failed, falling back (${String(
|
`device-pair: QR image send failed channel=${channel}, 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}`);
|
||||||
const infoLines = [
|
if (channel === "webchat") {
|
||||||
`Gateway: ${payload.url}`,
|
let qrDataUrl: string;
|
||||||
`Auth: ${authLabel}`,
|
try {
|
||||||
"",
|
qrDataUrl = await renderQrDataUrl(setupCode);
|
||||||
autoNotifyArmed
|
} catch (err) {
|
||||||
? "After scanning, wait here for the pairing request ping."
|
api.logger.warn?.(
|
||||||
: "After scanning, run `/pair approve` to complete pairing.",
|
`device-pair: webchat QR render failed, falling back (${String(
|
||||||
...(autoNotifyArmed
|
(err as Error)?.message ?? err,
|
||||||
? [
|
)})`,
|
||||||
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
|
);
|
||||||
"If the ping does not arrive, run `/pair approve latest` manually.",
|
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
|
||||||
]
|
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:
|
||||||
"Scan this QR code with the OpenClaw iOS app:",
|
"QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" +
|
||||||
"",
|
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 authLabel = authLabelResult.label ?? "auth";
|
const payload = await issueSetupPayload(urlResult.url);
|
||||||
|
|
||||||
if (channel === "telegram" && target) {
|
if (channel === "telegram" && target) {
|
||||||
try {
|
try {
|
||||||
@ -530,8 +785,10 @@ export default definePluginEntry({
|
|||||||
)})`,
|
)})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await send(target, formatSetupInstructions(), {
|
await send(target, formatSetupInstructions(payload.expiresAtMs), {
|
||||||
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
|
...(typeof ctx.messageThreadId === "number"
|
||||||
|
? { messageThreadId: ctx.messageThreadId }
|
||||||
|
: {}),
|
||||||
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
||||||
});
|
});
|
||||||
api.logger.info?.(
|
api.logger.info?.(
|
||||||
@ -548,7 +805,6 @@ 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?: number;
|
messageThreadId?: string | number;
|
||||||
mode: "persistent" | "once";
|
mode: "persistent" | "once";
|
||||||
addedAtMs: number;
|
addedAtMs: number;
|
||||||
};
|
};
|
||||||
@ -101,9 +101,11 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile {
|
|||||||
? record.accountId.trim()
|
? record.accountId.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const messageThreadId =
|
const messageThreadId =
|
||||||
typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
|
typeof record.messageThreadId === "string"
|
||||||
? Math.trunc(record.messageThreadId)
|
? record.messageThreadId.trim() || undefined
|
||||||
: undefined;
|
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
|
||||||
|
? 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)
|
||||||
@ -150,7 +152,7 @@ async function writeNotifyState(filePath: string, state: NotifyStateFile): Promi
|
|||||||
function notifySubscriberKey(subscriber: {
|
function notifySubscriberKey(subscriber: {
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: number;
|
messageThreadId?: string | number;
|
||||||
}): string {
|
}): string {
|
||||||
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
|
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
|
||||||
}
|
}
|
||||||
@ -158,7 +160,7 @@ function notifySubscriberKey(subscriber: {
|
|||||||
type NotifyTarget = {
|
type NotifyTarget = {
|
||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: number;
|
messageThreadId?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveNotifyTarget(ctx: {
|
function resolveNotifyTarget(ctx: {
|
||||||
@ -166,7 +168,7 @@ function resolveNotifyTarget(ctx: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: number;
|
messageThreadId?: string | 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) {
|
||||||
@ -261,7 +263,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 } : {}),
|
||||||
...(params.subscriber.messageThreadId != null
|
...(typeof params.subscriber.messageThreadId === "number"
|
||||||
? { messageThreadId: params.subscriber.messageThreadId }
|
? { messageThreadId: params.subscriber.messageThreadId }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
@ -347,7 +349,7 @@ export async function armPairNotifyOnce(params: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: number;
|
messageThreadId?: string | number;
|
||||||
};
|
};
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
if (params.ctx.channel !== "telegram") {
|
if (params.ctx.channel !== "telegram") {
|
||||||
@ -381,7 +383,7 @@ export async function handleNotifyCommand(params: {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
messageThreadId?: number;
|
messageThreadId?: string | number;
|
||||||
};
|
};
|
||||||
action: string;
|
action: string;
|
||||||
}): Promise<{ text: string }> {
|
}): Promise<{ text: string }> {
|
||||||
|
|||||||
54
extensions/device-pair/qr-image.ts
Normal file
54
extensions/device-pair/qr-image.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
@ -92,6 +92,7 @@ import { resolveDiscordPresenceUpdate } from "./presence.js";
|
|||||||
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
||||||
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
|
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
|
||||||
import { resolveDiscordRestFetch } from "./rest-fetch.js";
|
import { resolveDiscordRestFetch } from "./rest-fetch.js";
|
||||||
|
import { formatDiscordStartupStatusMessage } from "./startup-status.js";
|
||||||
import type { DiscordMonitorStatusSink } from "./status.js";
|
import type { DiscordMonitorStatusSink } from "./status.js";
|
||||||
import {
|
import {
|
||||||
createNoopThreadBindingManager,
|
createNoopThreadBindingManager,
|
||||||
@ -972,7 +973,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
const botIdentity =
|
const botIdentity =
|
||||||
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
|
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
|
||||||
runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`);
|
runtime.log?.(
|
||||||
|
formatDiscordStartupStatusMessage({
|
||||||
|
gatewayReady: lifecycleGateway?.isConnected === true,
|
||||||
|
botIdentity: botIdentity || undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (lifecycleGateway?.isConnected) {
|
if (lifecycleGateway?.isConnected) {
|
||||||
opts.setStatus?.(createConnectedChannelStatusPatch());
|
opts.setStatus?.(createConnectedChannelStatusPatch());
|
||||||
}
|
}
|
||||||
|
|||||||
30
extensions/discord/src/monitor/startup-status.test.ts
Normal file
30
extensions/discord/src/monitor/startup-status.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatDiscordStartupStatusMessage } from "./startup-status.js";
|
||||||
|
|
||||||
|
describe("formatDiscordStartupStatusMessage", () => {
|
||||||
|
it("reports logged-in status only after the gateway is ready", () => {
|
||||||
|
expect(
|
||||||
|
formatDiscordStartupStatusMessage({
|
||||||
|
gatewayReady: true,
|
||||||
|
botIdentity: "bot-1 (Molty)",
|
||||||
|
}),
|
||||||
|
).toBe("logged in to discord as bot-1 (Molty)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports client initialization while gateway readiness is still pending", () => {
|
||||||
|
expect(
|
||||||
|
formatDiscordStartupStatusMessage({
|
||||||
|
gatewayReady: false,
|
||||||
|
botIdentity: "bot-1 (Molty)",
|
||||||
|
}),
|
||||||
|
).toBe("discord client initialized as bot-1 (Molty); awaiting gateway readiness");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing identity without awkward punctuation", () => {
|
||||||
|
expect(
|
||||||
|
formatDiscordStartupStatusMessage({
|
||||||
|
gatewayReady: false,
|
||||||
|
}),
|
||||||
|
).toBe("discord client initialized; awaiting gateway readiness");
|
||||||
|
});
|
||||||
|
});
|
||||||
10
extensions/discord/src/monitor/startup-status.ts
Normal file
10
extensions/discord/src/monitor/startup-status.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export function formatDiscordStartupStatusMessage(params: {
|
||||||
|
gatewayReady: boolean;
|
||||||
|
botIdentity?: string;
|
||||||
|
}): string {
|
||||||
|
const identitySuffix = params.botIdentity ? ` as ${params.botIdentity}` : "";
|
||||||
|
if (params.gatewayReady) {
|
||||||
|
return `logged in to discord${identitySuffix}`;
|
||||||
|
}
|
||||||
|
return `discord client initialized${identitySuffix}; awaiting gateway readiness`;
|
||||||
|
}
|
||||||
@ -51,16 +51,18 @@ type FeishuThreadBindingsState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
|
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
|
||||||
const state = resolveGlobalSingleton<FeishuThreadBindingsState>(
|
let state: FeishuThreadBindingsState | undefined;
|
||||||
FEISHU_THREAD_BINDINGS_STATE_KEY,
|
|
||||||
() => ({
|
|
||||||
managersByAccountId: new Map(),
|
|
||||||
bindingsByAccountConversation: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId;
|
function getState(): FeishuThreadBindingsState {
|
||||||
const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation;
|
state ??= resolveGlobalSingleton<FeishuThreadBindingsState>(
|
||||||
|
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}`;
|
||||||
@ -119,7 +121,7 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
}): FeishuThreadBindingManager {
|
}): FeishuThreadBindingManager {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
const existing = getState().managersByAccountId.get(accountId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@ -138,9 +140,11 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
const manager: FeishuThreadBindingManager = {
|
const manager: FeishuThreadBindingManager = {
|
||||||
accountId,
|
accountId,
|
||||||
getByConversationId: (conversationId) =>
|
getByConversationId: (conversationId) =>
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })),
|
getState().bindingsByAccountConversation.get(
|
||||||
|
resolveBindingKey({ accountId, conversationId }),
|
||||||
|
),
|
||||||
listBySessionKey: (targetSessionKey) =>
|
listBySessionKey: (targetSessionKey) =>
|
||||||
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
[...getState().bindingsByAccountConversation.values()].filter(
|
||||||
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
|
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
|
||||||
),
|
),
|
||||||
bindConversation: ({
|
bindConversation: ({
|
||||||
@ -184,7 +188,7 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
boundAt: now,
|
boundAt: now,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
};
|
};
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
|
getState().bindingsByAccountConversation.set(
|
||||||
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
|
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
|
||||||
record,
|
record,
|
||||||
);
|
);
|
||||||
@ -192,30 +196,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 = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
const existingRecord = getState().bindingsByAccountConversation.get(key);
|
||||||
if (!existingRecord) {
|
if (!existingRecord) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const updated = { ...existingRecord, lastActivityAt: at };
|
const updated = { ...existingRecord, lastActivityAt: at };
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated);
|
getState().bindingsByAccountConversation.set(key, updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
unbindConversation: (conversationId) => {
|
unbindConversation: (conversationId) => {
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
const existingRecord = getState().bindingsByAccountConversation.get(key);
|
||||||
if (!existingRecord) {
|
if (!existingRecord) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
getState().bindingsByAccountConversation.delete(key);
|
||||||
return existingRecord;
|
return existingRecord;
|
||||||
},
|
},
|
||||||
unbindBySessionKey: (targetSessionKey) => {
|
unbindBySessionKey: (targetSessionKey) => {
|
||||||
const removed: FeishuThreadBindingRecord[] = [];
|
const removed: FeishuThreadBindingRecord[] = [];
|
||||||
for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) {
|
for (const record of [...getState().bindingsByAccountConversation.values()]) {
|
||||||
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
|
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(
|
getState().bindingsByAccountConversation.delete(
|
||||||
resolveBindingKey({ accountId, conversationId: record.conversationId }),
|
resolveBindingKey({ accountId, conversationId: record.conversationId }),
|
||||||
);
|
);
|
||||||
removed.push(record);
|
removed.push(record);
|
||||||
@ -223,12 +227,12 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
return removed;
|
return removed;
|
||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) {
|
for (const key of [...getState().bindingsByAccountConversation.keys()]) {
|
||||||
if (key.startsWith(`${accountId}:`)) {
|
if (key.startsWith(`${accountId}:`)) {
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
getState().bindingsByAccountConversation.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
getState().managersByAccountId.delete(accountId);
|
||||||
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
|
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -290,22 +294,22 @@ export function createFeishuThreadBindingManager(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
|
getState().managersByAccountId.set(accountId, manager);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeishuThreadBindingManager(
|
export function getFeishuThreadBindingManager(
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): FeishuThreadBindingManager | null {
|
): FeishuThreadBindingManager | null {
|
||||||
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
|
return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __testing = {
|
export const __testing = {
|
||||||
resetFeishuThreadBindingsForTests() {
|
resetFeishuThreadBindingsForTests() {
|
||||||
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
for (const manager of getState().managersByAccountId.values()) {
|
||||||
manager.stop();
|
manager.stop();
|
||||||
}
|
}
|
||||||
MANAGERS_BY_ACCOUNT_ID.clear();
|
getState().managersByAccountId.clear();
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
|
getState().bindingsByAccountConversation.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,7 +15,12 @@ const MAX_ENTRIES = 5000;
|
|||||||
*/
|
*/
|
||||||
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
|
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
|
||||||
|
|
||||||
const threadParticipation = resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
|
let threadParticipation: Map<string, number> | undefined;
|
||||||
|
|
||||||
|
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}`;
|
||||||
@ -23,17 +28,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 threadParticipation) {
|
for (const [key, timestamp] of getThreadParticipation()) {
|
||||||
if (now - timestamp > TTL_MS) {
|
if (now - timestamp > TTL_MS) {
|
||||||
threadParticipation.delete(key);
|
getThreadParticipation().delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evictOldest(): void {
|
function evictOldest(): void {
|
||||||
const oldest = threadParticipation.keys().next().value;
|
const oldest = getThreadParticipation().keys().next().value;
|
||||||
if (oldest) {
|
if (oldest) {
|
||||||
threadParticipation.delete(oldest);
|
getThreadParticipation().delete(oldest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +50,7 @@ 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();
|
||||||
}
|
}
|
||||||
@ -63,6 +69,7 @@ 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;
|
||||||
@ -75,5 +82,5 @@ export function hasSlackThreadParticipation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearSlackThreadParticipationCache(): void {
|
export function clearSlackThreadParticipationCache(): void {
|
||||||
threadParticipation.clear();
|
getThreadParticipation().clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,4 +54,28 @@ describe("fetchTelegramChatId", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses caller-provided fetch impl when present", async () => {
|
||||||
|
const customFetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true, result: { id: 12345 } }),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error("global fetch should not be called");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetchTelegramChatId({
|
||||||
|
token: "abc",
|
||||||
|
chatId: "@user",
|
||||||
|
fetchImpl: customFetch as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(customFetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.telegram.org/botabc/getChat?chat_id=%40user",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,48 @@
|
|||||||
|
import type { TelegramNetworkConfig } from "../runtime-api.js";
|
||||||
|
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
|
||||||
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
|
|
||||||
|
export function resolveTelegramChatLookupFetch(params?: {
|
||||||
|
proxyUrl?: string;
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
|
}): typeof fetch {
|
||||||
|
const proxyUrl = params?.proxyUrl?.trim();
|
||||||
|
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
||||||
|
return resolveTelegramFetch(proxyFetch, { network: params?.network });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupTelegramChatId(params: {
|
||||||
|
token: string;
|
||||||
|
chatId: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
apiRoot?: string;
|
||||||
|
proxyUrl?: string;
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
return fetchTelegramChatId({
|
||||||
|
token: params.token,
|
||||||
|
chatId: params.chatId,
|
||||||
|
signal: params.signal,
|
||||||
|
apiRoot: params.apiRoot,
|
||||||
|
fetchImpl: resolveTelegramChatLookupFetch({
|
||||||
|
proxyUrl: params.proxyUrl,
|
||||||
|
network: params.network,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTelegramChatId(params: {
|
export async function fetchTelegramChatId(params: {
|
||||||
token: string;
|
token: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
apiRoot?: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
const url = `https://api.telegram.org/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
|
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||||
|
const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
|
||||||
|
const fetchImpl = params.fetchImpl ?? fetch;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, params.signal ? { signal: params.signal } : undefined);
|
const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,9 @@ import type {
|
|||||||
TelegramGroupMembershipAudit,
|
TelegramGroupMembershipAudit,
|
||||||
TelegramGroupMembershipAuditEntry,
|
TelegramGroupMembershipAuditEntry,
|
||||||
} from "./audit.js";
|
} from "./audit.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
|
||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
|
|
||||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
||||||
|
|
||||||
type TelegramApiOk<T> = { ok: true; result: T };
|
type TelegramApiOk<T> = { ok: true; result: T };
|
||||||
type TelegramApiErr = { ok: false; description?: string };
|
type TelegramApiErr = { ok: false; description?: string };
|
||||||
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
|
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
|
||||||
@ -18,8 +16,11 @@ export async function auditTelegramGroupMembershipImpl(
|
|||||||
params: AuditTelegramGroupMembershipParams,
|
params: AuditTelegramGroupMembershipParams,
|
||||||
): Promise<TelegramGroupMembershipAuditData> {
|
): Promise<TelegramGroupMembershipAuditData> {
|
||||||
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
|
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
|
||||||
const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network });
|
const fetcher = resolveTelegramFetch(proxyFetch, {
|
||||||
const base = `${TELEGRAM_API_BASE}/bot${params.token}`;
|
network: params.network,
|
||||||
|
});
|
||||||
|
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||||
|
const base = `${apiBase}/bot${params.token}`;
|
||||||
const groups: TelegramGroupMembershipAuditEntry[] = [];
|
const groups: TelegramGroupMembershipAuditEntry[] = [];
|
||||||
|
|
||||||
for (const chatId of params.groupIds) {
|
for (const chatId of params.groupIds) {
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export type AuditTelegramGroupMembershipParams = {
|
|||||||
groupIds: string[];
|
groupIds: string[];
|
||||||
proxyUrl?: string;
|
proxyUrl?: string;
|
||||||
network?: TelegramNetworkConfig;
|
network?: TelegramNetworkConfig;
|
||||||
|
apiRoot?: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -361,7 +361,13 @@ export const registerTelegramHandlers = ({
|
|||||||
for (const { ctx } of entry.messages) {
|
for (const { ctx } of entry.messages) {
|
||||||
let media;
|
let media;
|
||||||
try {
|
try {
|
||||||
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
|
media = await resolveMedia(
|
||||||
|
ctx,
|
||||||
|
mediaMaxBytes,
|
||||||
|
opts.token,
|
||||||
|
telegramTransport,
|
||||||
|
telegramCfg.apiRoot,
|
||||||
|
);
|
||||||
} catch (mediaErr) {
|
} catch (mediaErr) {
|
||||||
if (!isRecoverableMediaGroupError(mediaErr)) {
|
if (!isRecoverableMediaGroupError(mediaErr)) {
|
||||||
throw mediaErr;
|
throw mediaErr;
|
||||||
@ -466,6 +472,7 @@ export const registerTelegramHandlers = ({
|
|||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
opts.token,
|
opts.token,
|
||||||
telegramTransport,
|
telegramTransport,
|
||||||
|
telegramCfg.apiRoot,
|
||||||
);
|
);
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return [];
|
return [];
|
||||||
@ -977,7 +984,13 @@ export const registerTelegramHandlers = ({
|
|||||||
|
|
||||||
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
|
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
|
||||||
try {
|
try {
|
||||||
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
|
media = await resolveMedia(
|
||||||
|
ctx,
|
||||||
|
mediaMaxBytes,
|
||||||
|
opts.token,
|
||||||
|
telegramTransport,
|
||||||
|
telegramCfg.apiRoot,
|
||||||
|
);
|
||||||
} catch (mediaErr) {
|
} catch (mediaErr) {
|
||||||
if (isMediaSizeLimitError(mediaErr)) {
|
if (isMediaSizeLimitError(mediaErr)) {
|
||||||
if (sendOversizeWarning) {
|
if (sendOversizeWarning) {
|
||||||
|
|||||||
@ -230,11 +230,13 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
||||||
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const apiRoot = telegramCfg.apiRoot?.trim() || undefined;
|
||||||
const client: ApiClientOptions | undefined =
|
const client: ApiClientOptions | undefined =
|
||||||
finalFetch || timeoutSeconds
|
finalFetch || timeoutSeconds || apiRoot
|
||||||
? {
|
? {
|
||||||
...(finalFetch ? { fetch: finalFetch } : {}),
|
...(finalFetch ? { fetch: finalFetch } : {}),
|
||||||
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
||||||
|
...(apiRoot ? { apiRoot } : {}),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@ -360,6 +360,38 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses local absolute file paths directly for media downloads", async () => {
|
||||||
|
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
|
||||||
|
|
||||||
|
const result = await resolveMedia(makeCtx("document", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
|
||||||
|
|
||||||
|
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||||
|
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "/var/lib/telegram-bot-api/file.pdf",
|
||||||
|
placeholder: "<media:document>",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses local absolute file paths directly for sticker downloads", async () => {
|
||||||
|
const getFile = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" });
|
||||||
|
|
||||||
|
const result = await resolveMedia(makeCtx("sticker", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
|
||||||
|
|
||||||
|
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||||
|
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "/var/lib/telegram-bot-api/sticker.webp",
|
||||||
|
placeholder: "<media:sticker>",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveMedia original filename preservation", () => {
|
describe("resolveMedia original filename preservation", () => {
|
||||||
|
|||||||
@ -1,21 +1,39 @@
|
|||||||
|
import path from "node:path";
|
||||||
import { GrammyError } from "grammy";
|
import { GrammyError } from "grammy";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { retryAsync } from "openclaw/plugin-sdk/infra-runtime";
|
import { retryAsync } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js";
|
import {
|
||||||
|
resolveTelegramApiBase,
|
||||||
|
shouldRetryTelegramTransportFallback,
|
||||||
|
type TelegramTransport,
|
||||||
|
} from "../fetch.js";
|
||||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||||
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
|
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
|
||||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||||
|
|
||||||
const FILE_TOO_BIG_RE = /file is too big/i;
|
const FILE_TOO_BIG_RE = /file is too big/i;
|
||||||
const TELEGRAM_MEDIA_SSRF_POLICY = {
|
function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
|
||||||
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
|
const hostnames = ["api.telegram.org"];
|
||||||
// resolution maps to private/internal ranges in restricted networks.
|
if (apiRoot) {
|
||||||
allowedHostnames: ["api.telegram.org"],
|
try {
|
||||||
allowRfc2544BenchmarkRange: true,
|
const customHost = new URL(apiRoot).hostname;
|
||||||
};
|
if (customHost && !hostnames.includes(customHost)) {
|
||||||
|
hostnames.push(customHost);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid URL; fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// Telegram file downloads should trust the API hostname even when DNS/proxy
|
||||||
|
// resolution maps to private/internal ranges in restricted networks.
|
||||||
|
allowedHostnames: hostnames,
|
||||||
|
allowRfc2544BenchmarkRange: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the error is Telegram's "file is too big" error.
|
* Returns true if the error is Telegram's "file is too big" error.
|
||||||
@ -124,8 +142,13 @@ async function downloadAndSaveTelegramFile(params: {
|
|||||||
transport: TelegramTransport;
|
transport: TelegramTransport;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
telegramFileName?: string;
|
telegramFileName?: string;
|
||||||
|
apiRoot?: string;
|
||||||
}) {
|
}) {
|
||||||
const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`;
|
if (path.isAbsolute(params.filePath)) {
|
||||||
|
return { path: params.filePath, contentType: undefined };
|
||||||
|
}
|
||||||
|
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||||
|
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
|
||||||
const fetched = await fetchRemoteMedia({
|
const fetched = await fetchRemoteMedia({
|
||||||
url,
|
url,
|
||||||
fetchImpl: params.transport.sourceFetch,
|
fetchImpl: params.transport.sourceFetch,
|
||||||
@ -134,7 +157,7 @@ async function downloadAndSaveTelegramFile(params: {
|
|||||||
filePathHint: params.filePath,
|
filePathHint: params.filePath,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
|
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||||
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
|
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot),
|
||||||
});
|
});
|
||||||
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
|
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
|
||||||
return saveMediaBuffer(
|
return saveMediaBuffer(
|
||||||
@ -152,6 +175,7 @@ async function resolveStickerMedia(params: {
|
|||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
token: string;
|
token: string;
|
||||||
transport?: TelegramTransport;
|
transport?: TelegramTransport;
|
||||||
|
apiRoot?: string;
|
||||||
}): Promise<
|
}): Promise<
|
||||||
| {
|
| {
|
||||||
path: string;
|
path: string;
|
||||||
@ -192,6 +216,7 @@ async function resolveStickerMedia(params: {
|
|||||||
token,
|
token,
|
||||||
transport: resolvedTransport,
|
transport: resolvedTransport,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
|
apiRoot: params.apiRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check sticker cache for existing description
|
// Check sticker cache for existing description
|
||||||
@ -247,6 +272,7 @@ export async function resolveMedia(
|
|||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
token: string,
|
token: string,
|
||||||
transport?: TelegramTransport,
|
transport?: TelegramTransport,
|
||||||
|
apiRoot?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@ -260,6 +286,7 @@ export async function resolveMedia(
|
|||||||
maxBytes,
|
maxBytes,
|
||||||
token,
|
token,
|
||||||
transport,
|
transport,
|
||||||
|
apiRoot,
|
||||||
});
|
});
|
||||||
if (stickerResolved !== undefined) {
|
if (stickerResolved !== undefined) {
|
||||||
return stickerResolved;
|
return stickerResolved;
|
||||||
@ -283,6 +310,7 @@ export async function resolveMedia(
|
|||||||
transport: resolveRequiredTelegramTransport(transport),
|
transport: resolveRequiredTelegramTransport(transport),
|
||||||
maxBytes,
|
maxBytes,
|
||||||
telegramFileName: resolveTelegramFileName(msg),
|
telegramFileName: resolveTelegramFileName(msg),
|
||||||
|
apiRoot,
|
||||||
});
|
});
|
||||||
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
||||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||||
|
|||||||
@ -586,6 +586,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
proxyUrl: account.config.proxy,
|
proxyUrl: account.config.proxy,
|
||||||
network: account.config.network,
|
network: account.config.network,
|
||||||
|
apiRoot: account.config.apiRoot,
|
||||||
}),
|
}),
|
||||||
formatCapabilitiesProbe: ({ probe }) => {
|
formatCapabilitiesProbe: ({ probe }) => {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@ -637,6 +638,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
groupIds,
|
groupIds,
|
||||||
proxyUrl: account.config.proxy,
|
proxyUrl: account.config.proxy,
|
||||||
network: account.config.network,
|
network: account.config.network,
|
||||||
|
apiRoot: account.config.apiRoot,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
|
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||||
@ -704,6 +706,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
proxyUrl: account.config.proxy,
|
proxyUrl: account.config.proxy,
|
||||||
network: account.config.network,
|
network: account.config.network,
|
||||||
|
apiRoot: account.config.apiRoot,
|
||||||
});
|
});
|
||||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||||
if (username) {
|
if (username) {
|
||||||
|
|||||||
@ -28,11 +28,17 @@ type TelegramSendMessageDraft = (
|
|||||||
*/
|
*/
|
||||||
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
|
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
|
||||||
|
|
||||||
const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
|
let draftStreamState: { nextDraftId: number } | undefined;
|
||||||
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;
|
||||||
@ -454,6 +460,6 @@ export function createTelegramDraftStream(params: {
|
|||||||
|
|
||||||
export const __testing = {
|
export const __testing = {
|
||||||
resetTelegramDraftStreamForTests() {
|
resetTelegramDraftStreamForTests() {
|
||||||
draftStreamState.nextDraftId = 0;
|
getDraftStreamState().nextDraftId = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -589,3 +589,12 @@ export function resolveTelegramFetch(
|
|||||||
): typeof fetch {
|
): typeof fetch {
|
||||||
return resolveTelegramTransport(proxyFetch, options).fetch;
|
return resolveTelegramTransport(proxyFetch, options).fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Telegram Bot API base URL from an optional `apiRoot` config value.
|
||||||
|
* Returns a trimmed URL without trailing slash, or the standard default.
|
||||||
|
*/
|
||||||
|
export function resolveTelegramApiBase(apiRoot?: string): string {
|
||||||
|
const trimmed = apiRoot?.trim();
|
||||||
|
return trimmed ? trimmed.replace(/\/+$/, "") : `https://${TELEGRAM_API_HOSTNAME}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -103,17 +103,34 @@ 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("//")) {
|
||||||
@ -134,8 +151,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(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
|
const wrappedStandalone = text.replace(getFileReferencePattern(), wrapStandaloneFileRef);
|
||||||
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
|
return wrappedStandalone.replace(getOrphanedTldPattern(), (match, prefix: string, tld: string) =>
|
||||||
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
|
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const makeProxyFetch = vi.hoisted(() => vi.fn());
|
|||||||
|
|
||||||
vi.mock("./fetch.js", () => ({
|
vi.mock("./fetch.js", () => ({
|
||||||
resolveTelegramFetch,
|
resolveTelegramFetch,
|
||||||
|
resolveTelegramApiBase: (apiRoot?: string) =>
|
||||||
|
apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./proxy.js", () => ({
|
vi.mock("./proxy.js", () => ({
|
||||||
@ -190,6 +192,7 @@ describe("probeTelegram retry logic", () => {
|
|||||||
autoSelectFamily: false,
|
autoSelectFamily: false,
|
||||||
dnsResultOrder: "ipv4first",
|
dnsResultOrder: "ipv4first",
|
||||||
},
|
},
|
||||||
|
apiRoot: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||||
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
|
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
|
||||||
import type { TelegramNetworkConfig } from "../runtime-api.js";
|
import type { TelegramNetworkConfig } from "../runtime-api.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
|
||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
|
|
||||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
||||||
|
|
||||||
export type TelegramProbe = BaseProbeResult & {
|
export type TelegramProbe = BaseProbeResult & {
|
||||||
status?: number | null;
|
status?: number | null;
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
@ -23,6 +21,7 @@ export type TelegramProbeOptions = {
|
|||||||
proxyUrl?: string;
|
proxyUrl?: string;
|
||||||
network?: TelegramNetworkConfig;
|
network?: TelegramNetworkConfig;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
apiRoot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const probeFetcherCache = new Map<string, typeof fetch>();
|
const probeFetcherCache = new Map<string, typeof fetch>();
|
||||||
@ -56,7 +55,8 @@ function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions
|
|||||||
const autoSelectFamilyKey =
|
const autoSelectFamilyKey =
|
||||||
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
|
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
|
||||||
const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default";
|
const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default";
|
||||||
return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`;
|
const apiRootKey = options?.apiRoot?.trim() ?? "";
|
||||||
|
return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch {
|
function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch {
|
||||||
@ -82,7 +82,9 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ
|
|||||||
|
|
||||||
const proxyUrl = options?.proxyUrl?.trim();
|
const proxyUrl = options?.proxyUrl?.trim();
|
||||||
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
||||||
const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network });
|
const resolved = resolveTelegramFetch(proxyFetch, {
|
||||||
|
network: options?.network,
|
||||||
|
});
|
||||||
|
|
||||||
if (cacheKey) {
|
if (cacheKey) {
|
||||||
return setCachedProbeFetcher(cacheKey, resolved);
|
return setCachedProbeFetcher(cacheKey, resolved);
|
||||||
@ -100,7 +102,8 @@ export async function probeTelegram(
|
|||||||
const deadlineMs = started + timeoutBudgetMs;
|
const deadlineMs = started + timeoutBudgetMs;
|
||||||
const options = resolveProbeOptions(proxyOrOptions);
|
const options = resolveProbeOptions(proxyOrOptions);
|
||||||
const fetcher = resolveProbeFetcher(token, options);
|
const fetcher = resolveProbeFetcher(token, options);
|
||||||
const base = `${TELEGRAM_API_BASE}/bot${token}`;
|
const apiBase = resolveTelegramApiBase(options?.apiRoot);
|
||||||
|
const base = `${apiBase}/bot${token}`;
|
||||||
const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5)));
|
const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5)));
|
||||||
const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now());
|
const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now());
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,8 @@ vi.mock("./proxy.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./fetch.js", () => ({
|
vi.mock("./fetch.js", () => ({
|
||||||
resolveTelegramFetch,
|
resolveTelegramFetch,
|
||||||
|
resolveTelegramApiBase: (apiRoot?: string) =>
|
||||||
|
apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import { withTelegramApiErrorLogging } from "./api-logging.js";
|
|||||||
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
|
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
|
||||||
import type { TelegramInlineButtons } from "./button-types.js";
|
import type { TelegramInlineButtons } from "./button-types.js";
|
||||||
import { splitTelegramCaption } from "./caption.js";
|
import { splitTelegramCaption } from "./caption.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
|
||||||
import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js";
|
import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js";
|
||||||
import {
|
import {
|
||||||
isRecoverableTelegramNetworkError,
|
isRecoverableTelegramNetworkError,
|
||||||
@ -192,9 +192,10 @@ function buildTelegramClientOptionsCacheKey(params: {
|
|||||||
const autoSelectFamilyKey =
|
const autoSelectFamilyKey =
|
||||||
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
|
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
|
||||||
const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default";
|
const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default";
|
||||||
|
const apiRootKey = params.account.config.apiRoot?.trim() ?? "";
|
||||||
const timeoutSecondsKey =
|
const timeoutSecondsKey =
|
||||||
typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default";
|
typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default";
|
||||||
return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`;
|
return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}::${timeoutSecondsKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedTelegramClientOptions(
|
function setCachedTelegramClientOptions(
|
||||||
@ -233,14 +234,16 @@ function resolveTelegramClientOptions(
|
|||||||
|
|
||||||
const proxyUrl = account.config.proxy?.trim();
|
const proxyUrl = account.config.proxy?.trim();
|
||||||
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
||||||
|
const apiRoot = account.config.apiRoot?.trim() || undefined;
|
||||||
const fetchImpl = resolveTelegramFetch(proxyFetch, {
|
const fetchImpl = resolveTelegramFetch(proxyFetch, {
|
||||||
network: account.config.network,
|
network: account.config.network,
|
||||||
});
|
});
|
||||||
const clientOptions =
|
const clientOptions =
|
||||||
fetchImpl || timeoutSeconds
|
fetchImpl || timeoutSeconds || apiRoot
|
||||||
? {
|
? {
|
||||||
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
|
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
|
||||||
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
||||||
|
...(apiRoot ? { apiRoot } : {}),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
if (cacheKey) {
|
if (cacheKey) {
|
||||||
|
|||||||
@ -17,7 +17,12 @@ type CacheEntry = {
|
|||||||
*/
|
*/
|
||||||
const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages");
|
const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages");
|
||||||
|
|
||||||
const sentMessages = resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY);
|
let sentMessages: Map<string, CacheEntry> | undefined;
|
||||||
|
|
||||||
|
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);
|
||||||
@ -37,6 +42,7 @@ 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() };
|
||||||
@ -54,7 +60,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 = sentMessages.get(key);
|
const entry = getSentMessages().get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -67,5 +73,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 {
|
||||||
sentMessages.clear();
|
getSentMessages().clear();
|
||||||
}
|
}
|
||||||
|
|||||||
46
extensions/telegram/src/setup-core.test.ts
Normal file
46
extensions/telegram/src/setup-core.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolveTelegramAllowFromEntries } from "./setup-core.js";
|
||||||
|
|
||||||
|
describe("resolveTelegramAllowFromEntries", () => {
|
||||||
|
it("passes apiRoot through username lookups", async () => {
|
||||||
|
const globalFetch = vi.fn(async () => {
|
||||||
|
throw new Error("global fetch should not be called");
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true, result: { id: 12345 } }),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal("fetch", globalFetch);
|
||||||
|
const proxyFetch = vi.fn();
|
||||||
|
const fetchModule = await import("./fetch.js");
|
||||||
|
const proxyModule = await import("./proxy.js");
|
||||||
|
const resolveTelegramFetch = vi.spyOn(fetchModule, "resolveTelegramFetch");
|
||||||
|
const makeProxyFetch = vi.spyOn(proxyModule, "makeProxyFetch");
|
||||||
|
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
|
||||||
|
resolveTelegramFetch.mockReturnValue(fetchMock as unknown as typeof fetch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await resolveTelegramAllowFromEntries({
|
||||||
|
entries: ["@user"],
|
||||||
|
credentialValue: "tok",
|
||||||
|
apiRoot: "https://custom.telegram.test/root/",
|
||||||
|
proxyUrl: "http://127.0.0.1:8080",
|
||||||
|
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual([{ input: "@user", resolved: true, id: "12345" }]);
|
||||||
|
expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8080");
|
||||||
|
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, {
|
||||||
|
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://custom.telegram.test/root/bottok/getChat?chat_id=%40user",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
makeProxyFetch.mockRestore();
|
||||||
|
resolveTelegramFetch.mockRestore();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -9,8 +9,9 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/setup";
|
} from "openclaw/plugin-sdk/setup";
|
||||||
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||||
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||||
|
import type { TelegramNetworkConfig } from "../runtime-api.js";
|
||||||
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
|
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
|
||||||
import { fetchTelegramChatId } from "./api-fetch.js";
|
import { lookupTelegramChatId } from "./api-fetch.js";
|
||||||
|
|
||||||
const channel = "telegram" as const;
|
const channel = "telegram" as const;
|
||||||
|
|
||||||
@ -46,6 +47,9 @@ export function parseTelegramAllowFromId(raw: string): string | null {
|
|||||||
export async function resolveTelegramAllowFromEntries(params: {
|
export async function resolveTelegramAllowFromEntries(params: {
|
||||||
entries: string[];
|
entries: string[];
|
||||||
credentialValue?: string;
|
credentialValue?: string;
|
||||||
|
apiRoot?: string;
|
||||||
|
proxyUrl?: string;
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
}) {
|
}) {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
params.entries.map(async (entry) => {
|
params.entries.map(async (entry) => {
|
||||||
@ -58,9 +62,12 @@ export async function resolveTelegramAllowFromEntries(params: {
|
|||||||
return { input: entry, resolved: false, id: null };
|
return { input: entry, resolved: false, id: null };
|
||||||
}
|
}
|
||||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||||
const id = await fetchTelegramChatId({
|
const id = await lookupTelegramChatId({
|
||||||
token: params.credentialValue,
|
token: params.credentialValue,
|
||||||
chatId: username,
|
chatId: username,
|
||||||
|
apiRoot: params.apiRoot,
|
||||||
|
proxyUrl: params.proxyUrl,
|
||||||
|
network: params.network,
|
||||||
});
|
});
|
||||||
return { input: entry, resolved: Boolean(id), id };
|
return { input: entry, resolved: Boolean(id), id };
|
||||||
}),
|
}),
|
||||||
@ -96,6 +103,9 @@ export async function promptTelegramAllowFromForAccount(params: {
|
|||||||
resolveTelegramAllowFromEntries({
|
resolveTelegramAllowFromEntries({
|
||||||
credentialValue: token,
|
credentialValue: token,
|
||||||
entries,
|
entries,
|
||||||
|
apiRoot: resolved.config.apiRoot,
|
||||||
|
proxyUrl: resolved.config.proxy,
|
||||||
|
network: resolved.config.network,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return patchChannelConfigForAccount({
|
return patchChannelConfigForAccount({
|
||||||
|
|||||||
@ -119,10 +119,11 @@ export const telegramSetupWizard: ChannelSetupWizard = {
|
|||||||
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
|
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
|
||||||
parseInputs: splitSetupEntries,
|
parseInputs: splitSetupEntries,
|
||||||
parseId: parseTelegramAllowFromId,
|
parseId: parseTelegramAllowFromId,
|
||||||
resolveEntries: async ({ credentialValues, entries }) =>
|
resolveEntries: async ({ cfg, accountId, credentialValues, entries }) =>
|
||||||
resolveTelegramAllowFromEntries({
|
resolveTelegramAllowFromEntries({
|
||||||
credentialValue: credentialValues.token,
|
credentialValue: credentialValues.token,
|
||||||
entries,
|
entries,
|
||||||
|
apiRoot: resolveTelegramAccount({ cfg, accountId }).config.apiRoot,
|
||||||
}),
|
}),
|
||||||
apply: async ({ cfg, accountId, allowFrom }) =>
|
apply: async ({ cfg, accountId, allowFrom }) =>
|
||||||
patchChannelConfigForAccount({
|
patchChannelConfigForAccount({
|
||||||
|
|||||||
@ -77,17 +77,19 @@ type TelegramThreadBindingsState = {
|
|||||||
*/
|
*/
|
||||||
const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState");
|
const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState");
|
||||||
|
|
||||||
const threadBindingsState = resolveGlobalSingleton<TelegramThreadBindingsState>(
|
let threadBindingsState: TelegramThreadBindingsState | undefined;
|
||||||
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
|
|
||||||
() => ({
|
function getThreadBindingsState(): TelegramThreadBindingsState {
|
||||||
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
|
threadBindingsState ??= resolveGlobalSingleton<TelegramThreadBindingsState>(
|
||||||
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
|
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
|
||||||
persistQueueByAccountId: new Map<string, Promise<void>>(),
|
() => ({
|
||||||
}),
|
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
|
||||||
);
|
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
|
||||||
const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId;
|
persistQueueByAccountId: new Map<string, Promise<void>>(),
|
||||||
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)) {
|
||||||
@ -168,7 +170,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 = BINDINGS_BY_ACCOUNT_CONVERSATION.get(
|
const existing = getThreadBindingsState().bindingsByAccountConversation.get(
|
||||||
resolveBindingKey({
|
resolveBindingKey({
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
conversationId: params.input.conversationId,
|
conversationId: params.input.conversationId,
|
||||||
@ -310,7 +312,7 @@ async function persistBindingsToDisk(params: {
|
|||||||
version: STORE_VERSION,
|
version: STORE_VERSION,
|
||||||
bindings:
|
bindings:
|
||||||
params.bindings ??
|
params.bindings ??
|
||||||
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
[...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
|
||||||
(entry) => entry.accountId === params.accountId,
|
(entry) => entry.accountId === params.accountId,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@ -322,7 +324,7 @@ async function persistBindingsToDisk(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
|
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
|
||||||
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
return [...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
|
||||||
(entry) => entry.accountId === accountId,
|
(entry) => entry.accountId === accountId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -335,16 +337,17 @@ function enqueuePersistBindings(params: {
|
|||||||
if (!params.persist) {
|
if (!params.persist) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve();
|
const previous =
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next);
|
getThreadBindingsState().persistQueueByAccountId.set(params.accountId, next);
|
||||||
void next.finally(() => {
|
void next.finally(() => {
|
||||||
if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) {
|
if (getThreadBindingsState().persistQueueByAccountId.get(params.accountId) === next) {
|
||||||
PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId);
|
getThreadBindingsState().persistQueueByAccountId.delete(params.accountId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@ -412,7 +415,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
} = {},
|
} = {},
|
||||||
): TelegramThreadBindingManager {
|
): TelegramThreadBindingManager {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
const existing = getThreadBindingsState().managersByAccountId.get(accountId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@ -430,7 +433,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
accountId,
|
accountId,
|
||||||
conversationId: entry.conversationId,
|
conversationId: entry.conversationId,
|
||||||
});
|
});
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, {
|
getThreadBindingsState().bindingsByAccountConversation.set(key, {
|
||||||
...entry,
|
...entry,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
@ -448,7 +451,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return BINDINGS_BY_ACCOUNT_CONVERSATION.get(
|
return getThreadBindingsState().bindingsByAccountConversation.get(
|
||||||
resolveBindingKey({
|
resolveBindingKey({
|
||||||
accountId,
|
accountId,
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -471,7 +474,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
const existing = getThreadBindingsState().bindingsByAccountConversation.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -479,7 +482,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
...existing,
|
...existing,
|
||||||
lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
|
lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
|
||||||
};
|
};
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord);
|
getThreadBindingsState().bindingsByAccountConversation.set(key, nextRecord);
|
||||||
persistBindingsSafely({
|
persistBindingsSafely({
|
||||||
accountId,
|
accountId,
|
||||||
persist: manager.shouldPersistMutations(),
|
persist: manager.shouldPersistMutations(),
|
||||||
@ -494,11 +497,11 @@ export function createTelegramThreadBindingManager(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const key = resolveBindingKey({ accountId, conversationId });
|
const key = resolveBindingKey({ accountId, conversationId });
|
||||||
const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null;
|
const removed = getThreadBindingsState().bindingsByAccountConversation.get(key) ?? null;
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
getThreadBindingsState().bindingsByAccountConversation.delete(key);
|
||||||
persistBindingsSafely({
|
persistBindingsSafely({
|
||||||
accountId,
|
accountId,
|
||||||
persist: manager.shouldPersistMutations(),
|
persist: manager.shouldPersistMutations(),
|
||||||
@ -521,7 +524,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
accountId,
|
accountId,
|
||||||
conversationId: entry.conversationId,
|
conversationId: entry.conversationId,
|
||||||
});
|
});
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
getThreadBindingsState().bindingsByAccountConversation.delete(key);
|
||||||
removed.push(entry);
|
removed.push(entry);
|
||||||
}
|
}
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
@ -540,9 +543,9 @@ export function createTelegramThreadBindingManager(
|
|||||||
sweepTimer = null;
|
sweepTimer = null;
|
||||||
}
|
}
|
||||||
unregisterSessionBindingAdapter({ channel: "telegram", accountId });
|
unregisterSessionBindingAdapter({ channel: "telegram", accountId });
|
||||||
const existingManager = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
const existingManager = getThreadBindingsState().managersByAccountId.get(accountId);
|
||||||
if (existingManager === manager) {
|
if (existingManager === manager) {
|
||||||
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
getThreadBindingsState().managersByAccountId.delete(accountId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -574,7 +577,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
|
getThreadBindingsState().bindingsByAccountConversation.set(
|
||||||
resolveBindingKey({ accountId, conversationId }),
|
resolveBindingKey({ accountId, conversationId }),
|
||||||
record,
|
record,
|
||||||
);
|
);
|
||||||
@ -714,14 +717,14 @@ export function createTelegramThreadBindingManager(
|
|||||||
sweepTimer.unref?.();
|
sweepTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
|
getThreadBindingsState().managersByAccountId.set(accountId, manager);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTelegramThreadBindingManager(
|
export function getTelegramThreadBindingManager(
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): TelegramThreadBindingManager | null {
|
): TelegramThreadBindingManager | null {
|
||||||
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
|
return getThreadBindingsState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTelegramBindingsBySessionKey(params: {
|
function updateTelegramBindingsBySessionKey(params: {
|
||||||
@ -741,7 +744,7 @@ function updateTelegramBindingsBySessionKey(params: {
|
|||||||
conversationId: entry.conversationId,
|
conversationId: entry.conversationId,
|
||||||
});
|
});
|
||||||
const next = params.update(entry, now);
|
const next = params.update(entry, now);
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, next);
|
getThreadBindingsState().bindingsByAccountConversation.set(key, next);
|
||||||
updated.push(next);
|
updated.push(next);
|
||||||
}
|
}
|
||||||
if (updated.length > 0) {
|
if (updated.length > 0) {
|
||||||
@ -799,12 +802,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: {
|
|||||||
|
|
||||||
export const __testing = {
|
export const __testing = {
|
||||||
async resetTelegramThreadBindingsForTests() {
|
async resetTelegramThreadBindingsForTests() {
|
||||||
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
for (const manager of getThreadBindingsState().managersByAccountId.values()) {
|
||||||
manager.stop();
|
manager.stop();
|
||||||
}
|
}
|
||||||
await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values());
|
await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values());
|
||||||
PERSIST_QUEUE_BY_ACCOUNT_ID.clear();
|
getThreadBindingsState().persistQueueByAccountId.clear();
|
||||||
MANAGERS_BY_ACCOUNT_ID.clear();
|
getThreadBindingsState().managersByAccountId.clear();
|
||||||
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
|
getThreadBindingsState().bindingsByAccountConversation.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@ -32,6 +33,21 @@ 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>;
|
||||||
@ -92,7 +108,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
value: actual.resolveStorePath,
|
value:
|
||||||
|
typeof actual.resolveStorePath === "function"
|
||||||
|
? actual.resolveStorePath
|
||||||
|
: resolveStorePathFallback,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return mockModule;
|
return mockModule;
|
||||||
|
|||||||
@ -125,6 +125,27 @@ describe("formatAssistantErrorText", () => {
|
|||||||
const msg = makeAssistantError("request ended without sending any chunks");
|
const msg = makeAssistantError("request ended without sending any chunks");
|
||||||
expect(formatAssistantErrorText(msg)).toBe("LLM request timed out.");
|
expect(formatAssistantErrorText(msg)).toBe("LLM request timed out.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a connection-refused message for ECONNREFUSED failures", () => {
|
||||||
|
const msg = makeAssistantError("connect ECONNREFUSED 127.0.0.1:443 during upstream call");
|
||||||
|
expect(formatAssistantErrorText(msg)).toBe(
|
||||||
|
"LLM request failed: connection refused by the provider endpoint.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a DNS-specific message for provider lookup failures", () => {
|
||||||
|
const msg = makeAssistantError("dial tcp: lookup api.example.com: no such host (ENOTFOUND)");
|
||||||
|
expect(formatAssistantErrorText(msg)).toBe(
|
||||||
|
"LLM request failed: DNS lookup for the provider endpoint failed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an interrupted-connection message for socket hang ups", () => {
|
||||||
|
const msg = makeAssistantError("socket hang up");
|
||||||
|
expect(formatAssistantErrorText(msg)).toBe(
|
||||||
|
"LLM request failed: network connection was interrupted.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatRawAssistantErrorForUi", () => {
|
describe("formatRawAssistantErrorForUi", () => {
|
||||||
|
|||||||
@ -88,6 +88,14 @@ describe("sanitizeUserFacingText", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a transport-specific message for prefixed ECONNREFUSED errors", () => {
|
||||||
|
expect(
|
||||||
|
sanitizeUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", {
|
||||||
|
errorContext: true,
|
||||||
|
}),
|
||||||
|
).toBe("LLM request failed: connection refused by the provider endpoint.");
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
input: "Hello there!\n\nHello there!",
|
input: "Hello there!\n\nHello there!",
|
||||||
|
|||||||
@ -65,6 +65,57 @@ function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTransportErrorCopy(raw: string): string | undefined {
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\beconnrefused\b/i.test(raw) ||
|
||||||
|
lower.includes("connection refused") ||
|
||||||
|
lower.includes("actively refused")
|
||||||
|
) {
|
||||||
|
return "LLM request failed: connection refused by the provider endpoint.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) ||
|
||||||
|
lower.includes("socket hang up") ||
|
||||||
|
lower.includes("connection reset") ||
|
||||||
|
lower.includes("connection aborted")
|
||||||
|
) {
|
||||||
|
return "LLM request failed: network connection was interrupted.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\benotfound\b|\beai_again\b/i.test(raw) ||
|
||||||
|
lower.includes("getaddrinfo") ||
|
||||||
|
lower.includes("no such host") ||
|
||||||
|
lower.includes("dns")
|
||||||
|
) {
|
||||||
|
return "LLM request failed: DNS lookup for the provider endpoint failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) ||
|
||||||
|
lower.includes("network is unreachable") ||
|
||||||
|
lower.includes("host is unreachable")
|
||||||
|
) {
|
||||||
|
return "LLM request failed: the provider endpoint is unreachable from this host.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes("fetch failed") ||
|
||||||
|
lower.includes("connection error") ||
|
||||||
|
lower.includes("network request failed")
|
||||||
|
) {
|
||||||
|
return "LLM request failed: network connection error.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function isReasoningConstraintErrorMessage(raw: string): boolean {
|
function isReasoningConstraintErrorMessage(raw: string): boolean {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return false;
|
return false;
|
||||||
@ -566,6 +617,11 @@ export function formatAssistantErrorText(
|
|||||||
return transientCopy;
|
return transientCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const transportCopy = formatTransportErrorCopy(raw);
|
||||||
|
if (transportCopy) {
|
||||||
|
return transportCopy;
|
||||||
|
}
|
||||||
|
|
||||||
if (isTimeoutErrorMessage(raw)) {
|
if (isTimeoutErrorMessage(raw)) {
|
||||||
return "LLM request timed out.";
|
return "LLM request timed out.";
|
||||||
}
|
}
|
||||||
@ -626,6 +682,10 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
|
|||||||
if (prefixedCopy) {
|
if (prefixedCopy) {
|
||||||
return prefixedCopy;
|
return prefixedCopy;
|
||||||
}
|
}
|
||||||
|
const transportCopy = formatTransportErrorCopy(trimmed);
|
||||||
|
if (transportCopy) {
|
||||||
|
return transportCopy;
|
||||||
|
}
|
||||||
if (isTimeoutErrorMessage(trimmed)) {
|
if (isTimeoutErrorMessage(trimmed)) {
|
||||||
return "LLM request timed out.";
|
return "LLM request timed out.";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,14 +58,16 @@ describe("handleAgentEnd", () => {
|
|||||||
expect(warn.mock.calls[0]?.[1]).toMatchObject({
|
expect(warn.mock.calls[0]?.[1]).toMatchObject({
|
||||||
event: "embedded_run_agent_end",
|
event: "embedded_run_agent_end",
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
error: "connection refused",
|
error: "LLM request failed: connection refused by the provider endpoint.",
|
||||||
rawErrorPreview: "connection refused",
|
rawErrorPreview: "connection refused",
|
||||||
|
consoleMessage:
|
||||||
|
"embedded run agent end: runId=run-1 isError=true model=unknown provider=unknown error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused",
|
||||||
});
|
});
|
||||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
phase: "error",
|
phase: "error",
|
||||||
error: "connection refused",
|
error: "LLM request failed: connection refused by the provider endpoint.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -92,7 +94,7 @@ describe("handleAgentEnd", () => {
|
|||||||
failoverReason: "overloaded",
|
failoverReason: "overloaded",
|
||||||
providerErrorType: "overloaded_error",
|
providerErrorType: "overloaded_error",
|
||||||
consoleMessage:
|
consoleMessage:
|
||||||
"embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment.",
|
'embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment. rawError={"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,7 +114,7 @@ describe("handleAgentEnd", () => {
|
|||||||
const meta = warn.mock.calls[0]?.[1];
|
const meta = warn.mock.calls[0]?.[1];
|
||||||
expect(meta).toMatchObject({
|
expect(meta).toMatchObject({
|
||||||
consoleMessage:
|
consoleMessage:
|
||||||
"embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=connection refused",
|
"embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused",
|
||||||
});
|
});
|
||||||
expect(meta?.consoleMessage).not.toContain("\n");
|
expect(meta?.consoleMessage).not.toContain("\n");
|
||||||
expect(meta?.consoleMessage).not.toContain("\r");
|
expect(meta?.consoleMessage).not.toContain("\r");
|
||||||
|
|||||||
@ -50,6 +50,8 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-";
|
const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-";
|
||||||
const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown";
|
const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown";
|
||||||
const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown";
|
const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown";
|
||||||
|
const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview);
|
||||||
|
const rawErrorConsoleSuffix = safeRawErrorPreview ? ` rawError=${safeRawErrorPreview}` : "";
|
||||||
ctx.log.warn("embedded run agent end", {
|
ctx.log.warn("embedded run agent end", {
|
||||||
event: "embedded_run_agent_end",
|
event: "embedded_run_agent_end",
|
||||||
tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"],
|
tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"],
|
||||||
@ -60,7 +62,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
model: lastAssistant.model,
|
model: lastAssistant.model,
|
||||||
provider: lastAssistant.provider,
|
provider: lastAssistant.provider,
|
||||||
...observedError,
|
...observedError,
|
||||||
consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}`,
|
consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}${rawErrorConsoleSuffix}`,
|
||||||
});
|
});
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: ctx.params.runId,
|
runId: ctx.params.runId,
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const readLatestAssistantReplyMock = vi.fn<(sessionKey: string) => Promise<string | undefined>>(
|
|
||||||
async (_sessionKey: string) => undefined,
|
|
||||||
);
|
|
||||||
const chatHistoryMock = vi.fn<(sessionKey: string) => Promise<{ messages?: Array<unknown> }>>(
|
const chatHistoryMock = vi.fn<(sessionKey: string) => Promise<{ messages?: Array<unknown> }>>(
|
||||||
async (_sessionKey: string) => ({ messages: [] }),
|
async (_sessionKey: string) => ({ messages: [] }),
|
||||||
);
|
);
|
||||||
@ -17,10 +14,6 @@ vi.mock("../gateway/call.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./tools/agent-step.js", () => ({
|
|
||||||
readLatestAssistantReply: readLatestAssistantReplyMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("captureSubagentCompletionReply", () => {
|
describe("captureSubagentCompletionReply", () => {
|
||||||
let previousFastTestEnv: string | undefined;
|
let previousFastTestEnv: string | undefined;
|
||||||
let captureSubagentCompletionReply: (typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"];
|
let captureSubagentCompletionReply: (typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"];
|
||||||
@ -40,23 +33,27 @@ describe("captureSubagentCompletionReply", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
readLatestAssistantReplyMock.mockReset().mockResolvedValue(undefined);
|
|
||||||
chatHistoryMock.mockReset().mockResolvedValue({ messages: [] });
|
chatHistoryMock.mockReset().mockResolvedValue({ messages: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns immediate assistant output without polling", async () => {
|
it("returns immediate assistant output from history without polling", async () => {
|
||||||
readLatestAssistantReplyMock.mockResolvedValueOnce("Immediate assistant completion");
|
chatHistoryMock.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Immediate assistant completion" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await captureSubagentCompletionReply("agent:main:subagent:child");
|
const result = await captureSubagentCompletionReply("agent:main:subagent:child");
|
||||||
|
|
||||||
expect(result).toBe("Immediate assistant completion");
|
expect(result).toBe("Immediate assistant completion");
|
||||||
expect(readLatestAssistantReplyMock).toHaveBeenCalledTimes(1);
|
expect(chatHistoryMock).toHaveBeenCalledTimes(1);
|
||||||
expect(chatHistoryMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("polls briefly and returns late tool output once available", async () => {
|
it("polls briefly and returns late tool output once available", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
readLatestAssistantReplyMock.mockResolvedValue(undefined);
|
|
||||||
chatHistoryMock.mockResolvedValueOnce({ messages: [] }).mockResolvedValueOnce({
|
chatHistoryMock.mockResolvedValueOnce({ messages: [] }).mockResolvedValueOnce({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@ -82,7 +79,6 @@ describe("captureSubagentCompletionReply", () => {
|
|||||||
|
|
||||||
it("returns undefined when no completion output arrives before retry window closes", async () => {
|
it("returns undefined when no completion output arrives before retry window closes", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
readLatestAssistantReplyMock.mockResolvedValue(undefined);
|
|
||||||
chatHistoryMock.mockResolvedValue({ messages: [] });
|
chatHistoryMock.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
const pending = captureSubagentCompletionReply("agent:main:subagent:child");
|
const pending = captureSubagentCompletionReply("agent:main:subagent:child");
|
||||||
@ -93,4 +89,26 @@ describe("captureSubagentCompletionReply", () => {
|
|||||||
expect(chatHistoryMock).toHaveBeenCalled();
|
expect(chatHistoryMock).toHaveBeenCalled();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns partial assistant progress when the latest assistant turn is tool-only", async () => {
|
||||||
|
chatHistoryMock.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Mapped the modules." },
|
||||||
|
{ type: "toolCall", id: "call-1", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call-2", name: "exec", arguments: {} }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await captureSubagentCompletionReply("agent:main:subagent:child");
|
||||||
|
|
||||||
|
expect(result).toBe("Mapped the modules.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -29,10 +29,14 @@ let fallbackRequesterResolution: {
|
|||||||
requesterSessionKey: string;
|
requesterSessionKey: string;
|
||||||
requesterOrigin?: { channel?: string; to?: string; accountId?: string };
|
requesterOrigin?: { channel?: string; to?: string; accountId?: string };
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
let chatHistoryMessages: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
vi.mock("../gateway/call.js", () => ({
|
vi.mock("../gateway/call.js", () => ({
|
||||||
callGateway: vi.fn(async (request: GatewayCall) => {
|
callGateway: vi.fn(async (request: GatewayCall) => {
|
||||||
gatewayCalls.push(request);
|
gatewayCalls.push(request);
|
||||||
|
if (request.method === "chat.history") {
|
||||||
|
return { messages: chatHistoryMessages };
|
||||||
|
}
|
||||||
return await callGatewayImpl(request);
|
return await callGatewayImpl(request);
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@ -138,6 +142,7 @@ function setupParentSessionFallback(parentSessionKey: string): void {
|
|||||||
describe("subagent announce timeout config", () => {
|
describe("subagent announce timeout config", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
gatewayCalls.length = 0;
|
gatewayCalls.length = 0;
|
||||||
|
chatHistoryMessages = [];
|
||||||
callGatewayImpl = async (request) => {
|
callGatewayImpl = async (request) => {
|
||||||
if (request.method === "chat.history") {
|
if (request.method === "chat.history") {
|
||||||
return { messages: [] };
|
return { messages: [] };
|
||||||
@ -270,7 +275,6 @@ describe("subagent announce timeout config", () => {
|
|||||||
it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => {
|
it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => {
|
||||||
const parentSessionKey = "agent:main:subagent:parent";
|
const parentSessionKey = "agent:main:subagent:parent";
|
||||||
setupParentSessionFallback(parentSessionKey);
|
setupParentSessionFallback(parentSessionKey);
|
||||||
// No sessionId on purpose: existence in store should still count as alive.
|
|
||||||
sessionStore[parentSessionKey] = { updatedAt: Date.now() };
|
sessionStore[parentSessionKey] = { updatedAt: Date.now() };
|
||||||
|
|
||||||
await runAnnounceFlowForTest("run-parent-route", {
|
await runAnnounceFlowForTest("run-parent-route", {
|
||||||
@ -301,4 +305,147 @@ describe("subagent announce timeout config", () => {
|
|||||||
expect(directAgentCall?.params?.to).toBe("chan-main");
|
expect(directAgentCall?.params?.to).toBe("chan-main");
|
||||||
expect(directAgentCall?.params?.accountId).toBe("acct-main");
|
expect(directAgentCall?.params?.accountId).toBe("acct-main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses partial progress on timeout when the child only made tool calls", async () => {
|
||||||
|
chatHistoryMessages = [
|
||||||
|
{ role: "user", content: "do a complex task" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
|
||||||
|
},
|
||||||
|
{ role: "toolResult", toolCallId: "call-1", content: [{ type: "text", text: "data" }] },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call-2", name: "exec", arguments: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call-3", name: "search", arguments: {} }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await runAnnounceFlowForTest("run-timeout-partial-progress", {
|
||||||
|
outcome: { status: "timeout" },
|
||||||
|
roundOneReply: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const directAgentCall = findFinalDirectAgentCall();
|
||||||
|
const internalEvents =
|
||||||
|
(directAgentCall?.params?.internalEvents as Array<{ result?: string }>) ?? [];
|
||||||
|
expect(internalEvents[0]?.result).toContain("3 tool call(s)");
|
||||||
|
expect(internalEvents[0]?.result).not.toContain("data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves NO_REPLY when timeout history ends with silence after earlier progress", async () => {
|
||||||
|
chatHistoryMessages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Still working through the files." },
|
||||||
|
{ type: "toolCall", id: "call-1", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "NO_REPLY" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call-2", name: "exec", arguments: {} }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await runAnnounceFlowForTest("run-timeout-no-reply", {
|
||||||
|
outcome: { status: "timeout" },
|
||||||
|
roundOneReply: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findFinalDirectAgentCall()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers visible assistant progress over a later raw tool result", async () => {
|
||||||
|
chatHistoryMessages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Read 12 files. Narrowing the search now." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
content: [{ type: "text", text: "grep output" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await runAnnounceFlowForTest("run-timeout-visible-assistant", {
|
||||||
|
outcome: { status: "timeout" },
|
||||||
|
roundOneReply: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const directAgentCall = findFinalDirectAgentCall();
|
||||||
|
const internalEvents =
|
||||||
|
(directAgentCall?.params?.internalEvents as Array<{ result?: string }>) ?? [];
|
||||||
|
expect(internalEvents[0]?.result).toContain("Read 12 files");
|
||||||
|
expect(internalEvents[0]?.result).not.toContain("grep output");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves NO_REPLY when timeout partial-progress history mixes prior text and later silence", async () => {
|
||||||
|
chatHistoryMessages = [
|
||||||
|
{ role: "user", content: "do something" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Still working through the files." },
|
||||||
|
{ type: "toolCall", id: "call1", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "NO_REPLY" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call2", name: "exec", arguments: {} }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await runAnnounceFlowForTest("run-timeout-mixed-no-reply", {
|
||||||
|
outcome: { status: "timeout" },
|
||||||
|
roundOneReply: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findGatewayCall((call) => call.method === "agent" && call.expectFinal === true),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers NO_REPLY partial progress over a longer latest assistant reply", async () => {
|
||||||
|
chatHistoryMessages = [
|
||||||
|
{ role: "user", content: "do something" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Still working through the files." },
|
||||||
|
{ type: "toolCall", id: "call1", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "NO_REPLY" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "A longer partial summary that should stay silent." }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await runAnnounceFlowForTest("run-timeout-no-reply-overrides-latest-text", {
|
||||||
|
outcome: { status: "timeout" },
|
||||||
|
roundOneReply: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findGatewayCall((call) => call.method === "agent" && call.expectFinal === true),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -47,7 +47,6 @@ import {
|
|||||||
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
||||||
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
||||||
import type { SpawnSubagentMode } from "./subagent-spawn.js";
|
import type { SpawnSubagentMode } from "./subagent-spawn.js";
|
||||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
|
||||||
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
|
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
|
||||||
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
||||||
|
|
||||||
@ -55,7 +54,6 @@ const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1";
|
|||||||
const FAST_TEST_RETRY_INTERVAL_MS = 8;
|
const FAST_TEST_RETRY_INTERVAL_MS = 8;
|
||||||
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 90_000;
|
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 90_000;
|
||||||
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
|
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
|
||||||
const GATEWAY_TIMEOUT_PATTERN = /gateway timeout/i;
|
|
||||||
let subagentRegistryRuntimePromise: Promise<
|
let subagentRegistryRuntimePromise: Promise<
|
||||||
typeof import("./subagent-registry-runtime.js")
|
typeof import("./subagent-registry-runtime.js")
|
||||||
> | null = null;
|
> | null = null;
|
||||||
@ -74,6 +72,14 @@ type ToolResultMessage = {
|
|||||||
content?: unknown;
|
content?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SubagentOutputSnapshot = {
|
||||||
|
latestAssistantText?: string;
|
||||||
|
latestSilentText?: string;
|
||||||
|
latestRawText?: string;
|
||||||
|
assistantFragments: string[];
|
||||||
|
toolCallCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType<typeof loadConfig>): number {
|
function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType<typeof loadConfig>): number {
|
||||||
const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs;
|
const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs;
|
||||||
if (typeof configured !== "number" || !Number.isFinite(configured)) {
|
if (typeof configured !== "number" || !Number.isFinite(configured)) {
|
||||||
@ -110,7 +116,7 @@ const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
|
|||||||
/no active .* listener/i,
|
/no active .* listener/i,
|
||||||
/gateway not connected/i,
|
/gateway not connected/i,
|
||||||
/gateway closed \(1006/i,
|
/gateway closed \(1006/i,
|
||||||
GATEWAY_TIMEOUT_PATTERN,
|
/gateway timeout/i,
|
||||||
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
|
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -136,11 +142,6 @@ function isTransientAnnounceDeliveryError(error: unknown): boolean {
|
|||||||
return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message));
|
return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGatewayTimeoutError(error: unknown): boolean {
|
|
||||||
const message = summarizeDeliveryError(error);
|
|
||||||
return Boolean(message) && GATEWAY_TIMEOUT_PATTERN.test(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (ms <= 0) {
|
if (ms <= 0) {
|
||||||
return;
|
return;
|
||||||
@ -168,7 +169,6 @@ async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Prom
|
|||||||
|
|
||||||
async function runAnnounceDeliveryWithRetry<T>(params: {
|
async function runAnnounceDeliveryWithRetry<T>(params: {
|
||||||
operation: string;
|
operation: string;
|
||||||
noRetryOnGatewayTimeout?: boolean;
|
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
run: () => Promise<T>;
|
run: () => Promise<T>;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
@ -180,9 +180,6 @@ async function runAnnounceDeliveryWithRetry<T>(params: {
|
|||||||
try {
|
try {
|
||||||
return await params.run();
|
return await params.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (params.noRetryOnGatewayTimeout && isGatewayTimeoutError(err)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex];
|
const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex];
|
||||||
if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) {
|
if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) {
|
||||||
throw err;
|
throw err;
|
||||||
@ -287,42 +284,126 @@ function extractSubagentOutputText(message: unknown): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readLatestSubagentOutput(sessionKey: string): Promise<string | undefined> {
|
function countAssistantToolCalls(content: unknown): number {
|
||||||
try {
|
if (!Array.isArray(content)) {
|
||||||
const latestAssistant = await readLatestAssistantReply({
|
return 0;
|
||||||
sessionKey,
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
if (latestAssistant?.trim()) {
|
|
||||||
return latestAssistant;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort: fall back to richer history parsing below.
|
|
||||||
}
|
}
|
||||||
|
let count = 0;
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = (block as { type?: unknown }).type;
|
||||||
|
if (
|
||||||
|
type === "toolCall" ||
|
||||||
|
type === "tool_use" ||
|
||||||
|
type === "toolUse" ||
|
||||||
|
type === "functionCall" ||
|
||||||
|
type === "function_call"
|
||||||
|
) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSubagentOutputHistory(messages: Array<unknown>): SubagentOutputSnapshot {
|
||||||
|
const snapshot: SubagentOutputSnapshot = {
|
||||||
|
assistantFragments: [],
|
||||||
|
toolCallCount: 0,
|
||||||
|
};
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const role = (message as { role?: unknown }).role;
|
||||||
|
if (role === "assistant") {
|
||||||
|
snapshot.toolCallCount += countAssistantToolCalls((message as { content?: unknown }).content);
|
||||||
|
const text = extractSubagentOutputText(message).trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isAnnounceSkip(text) || isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||||
|
snapshot.latestSilentText = text;
|
||||||
|
snapshot.latestAssistantText = undefined;
|
||||||
|
snapshot.assistantFragments = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
snapshot.latestSilentText = undefined;
|
||||||
|
snapshot.latestAssistantText = text;
|
||||||
|
snapshot.assistantFragments.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = extractSubagentOutputText(message).trim();
|
||||||
|
if (text) {
|
||||||
|
snapshot.latestRawText = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubagentPartialProgress(
|
||||||
|
snapshot: SubagentOutputSnapshot,
|
||||||
|
outcome?: SubagentRunOutcome,
|
||||||
|
): string | undefined {
|
||||||
|
if (snapshot.latestSilentText) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timedOut = outcome?.status === "timeout";
|
||||||
|
if (snapshot.assistantFragments.length === 0 && (!timedOut || snapshot.toolCallCount === 0)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (timedOut && snapshot.toolCallCount > 0) {
|
||||||
|
parts.push(
|
||||||
|
`[Partial progress: ${snapshot.toolCallCount} tool call(s) executed before timeout]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.assistantFragments.length > 0) {
|
||||||
|
parts.push(snapshot.assistantFragments.slice(-3).join("\n\n---\n\n"));
|
||||||
|
}
|
||||||
|
return parts.join("\n\n") || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSubagentOutputText(
|
||||||
|
snapshot: SubagentOutputSnapshot,
|
||||||
|
outcome?: SubagentRunOutcome,
|
||||||
|
): string | undefined {
|
||||||
|
if (snapshot.latestSilentText) {
|
||||||
|
return snapshot.latestSilentText;
|
||||||
|
}
|
||||||
|
if (snapshot.latestAssistantText) {
|
||||||
|
return snapshot.latestAssistantText;
|
||||||
|
}
|
||||||
|
const partialProgress = formatSubagentPartialProgress(snapshot, outcome);
|
||||||
|
if (partialProgress) {
|
||||||
|
return partialProgress;
|
||||||
|
}
|
||||||
|
return snapshot.latestRawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSubagentOutput(
|
||||||
|
sessionKey: string,
|
||||||
|
outcome?: SubagentRunOutcome,
|
||||||
|
): Promise<string | undefined> {
|
||||||
const history = await callGateway<{ messages?: Array<unknown> }>({
|
const history = await callGateway<{ messages?: Array<unknown> }>({
|
||||||
method: "chat.history",
|
method: "chat.history",
|
||||||
params: { sessionKey, limit: 50 },
|
params: { sessionKey, limit: 100 },
|
||||||
});
|
});
|
||||||
const messages = Array.isArray(history?.messages) ? history.messages : [];
|
const messages = Array.isArray(history?.messages) ? history.messages : [];
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
return selectSubagentOutputText(summarizeSubagentOutputHistory(messages), outcome);
|
||||||
const msg = messages[i];
|
|
||||||
const text = extractSubagentOutputText(msg);
|
|
||||||
if (text) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readLatestSubagentOutputWithRetry(params: {
|
async function readLatestSubagentOutputWithRetry(params: {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
maxWaitMs: number;
|
maxWaitMs: number;
|
||||||
|
outcome?: SubagentRunOutcome;
|
||||||
}): Promise<string | undefined> {
|
}): Promise<string | undefined> {
|
||||||
const RETRY_INTERVAL_MS = FAST_TEST_MODE ? FAST_TEST_RETRY_INTERVAL_MS : 100;
|
const RETRY_INTERVAL_MS = FAST_TEST_MODE ? FAST_TEST_RETRY_INTERVAL_MS : 100;
|
||||||
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000));
|
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000));
|
||||||
let result: string | undefined;
|
let result: string | undefined;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
result = await readLatestSubagentOutput(params.sessionKey);
|
result = await readSubagentOutput(params.sessionKey, params.outcome);
|
||||||
if (result?.trim()) {
|
if (result?.trim()) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -334,7 +415,7 @@ async function readLatestSubagentOutputWithRetry(params: {
|
|||||||
export async function captureSubagentCompletionReply(
|
export async function captureSubagentCompletionReply(
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const immediate = await readLatestSubagentOutput(sessionKey);
|
const immediate = await readSubagentOutput(sessionKey);
|
||||||
if (immediate?.trim()) {
|
if (immediate?.trim()) {
|
||||||
return immediate;
|
return immediate;
|
||||||
}
|
}
|
||||||
@ -811,7 +892,6 @@ async function sendSubagentAnnounceDirectly(params: {
|
|||||||
operation: params.expectsCompletionMessage
|
operation: params.expectsCompletionMessage
|
||||||
? "completion direct announce agent call"
|
? "completion direct announce agent call"
|
||||||
: "direct announce agent call",
|
: "direct announce agent call",
|
||||||
noRetryOnGatewayTimeout: params.expectsCompletionMessage && shouldDeliverExternally,
|
|
||||||
signal: params.signal,
|
signal: params.signal,
|
||||||
run: async () =>
|
run: async () =>
|
||||||
await callGateway({
|
await callGateway({
|
||||||
@ -1321,13 +1401,14 @@ export async function runSubagentAnnounceFlow(params: {
|
|||||||
(isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN));
|
(isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN));
|
||||||
|
|
||||||
if (!reply) {
|
if (!reply) {
|
||||||
reply = await readLatestSubagentOutput(params.childSessionKey);
|
reply = await readSubagentOutput(params.childSessionKey, outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reply?.trim()) {
|
if (!reply?.trim()) {
|
||||||
reply = await readLatestSubagentOutputWithRetry({
|
reply = await readLatestSubagentOutputWithRetry({
|
||||||
sessionKey: params.childSessionKey,
|
sessionKey: params.childSessionKey,
|
||||||
maxWaitMs: params.timeoutMs,
|
maxWaitMs: params.timeoutMs,
|
||||||
|
outcome,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,10 @@ 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 === "number" ? params.ctx.MessageThreadId : undefined,
|
typeof params.ctx.MessageThreadId === "string" ||
|
||||||
|
typeof params.ctx.MessageThreadId === "number"
|
||||||
|
? params.ctx.MessageThreadId
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||||
|
import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js";
|
||||||
import * as noteModule from "../terminal/note.js";
|
import * as noteModule from "../terminal/note.js";
|
||||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||||
@ -516,8 +517,11 @@ describe("doctor config flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => {
|
it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => {
|
||||||
const fetchSpy = vi.fn(async (url: string) => {
|
const globalFetch = vi.fn(async () => {
|
||||||
const u = String(url);
|
throw new Error("global fetch should not be called");
|
||||||
|
});
|
||||||
|
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const u = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
||||||
const chatId = new URL(u).searchParams.get("chat_id") ?? "";
|
const chatId = new URL(u).searchParams.get("chat_id") ?? "";
|
||||||
const id =
|
const id =
|
||||||
chatId.toLowerCase() === "@testuser"
|
chatId.toLowerCase() === "@testuser"
|
||||||
@ -534,7 +538,14 @@ describe("doctor config flow", () => {
|
|||||||
json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }),
|
json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }),
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
vi.stubGlobal("fetch", globalFetch);
|
||||||
|
const proxyFetch = vi.fn();
|
||||||
|
const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js");
|
||||||
|
const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js");
|
||||||
|
const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch");
|
||||||
|
const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch");
|
||||||
|
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
|
||||||
|
resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch);
|
||||||
try {
|
try {
|
||||||
const result = await runDoctorConfigWithInput({
|
const result = await runDoctorConfigWithInput({
|
||||||
repair: true,
|
repair: true,
|
||||||
@ -580,6 +591,8 @@ describe("doctor config flow", () => {
|
|||||||
expect(cfg.channels.telegram.accounts.default.allowFrom).toEqual(["111"]);
|
expect(cfg.channels.telegram.accounts.default.allowFrom).toEqual(["111"]);
|
||||||
expect(cfg.channels.telegram.accounts.default.groupAllowFrom).toEqual(["222"]);
|
expect(cfg.channels.telegram.accounts.default.groupAllowFrom).toEqual(["222"]);
|
||||||
} finally {
|
} finally {
|
||||||
|
makeProxyFetch.mockRestore();
|
||||||
|
resolveTelegramFetch.mockRestore();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -632,6 +645,88 @@ describe("doctor config flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses account apiRoot when repairing Telegram allowFrom usernames", async () => {
|
||||||
|
const globalFetch = vi.fn(async () => {
|
||||||
|
throw new Error("global fetch should not be called");
|
||||||
|
});
|
||||||
|
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
||||||
|
expect(url).toBe("https://custom.telegram.test/root/bottok/getChat?chat_id=%40testuser");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true, result: { id: 12345 } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", globalFetch);
|
||||||
|
const proxyFetch = vi.fn();
|
||||||
|
const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js");
|
||||||
|
const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js");
|
||||||
|
const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch");
|
||||||
|
const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch");
|
||||||
|
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
|
||||||
|
resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch);
|
||||||
|
const resolveSecretsSpy = vi
|
||||||
|
.spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway")
|
||||||
|
.mockResolvedValue({
|
||||||
|
diagnostics: [],
|
||||||
|
targetStatesByPath: {},
|
||||||
|
hadUnresolvedTargets: false,
|
||||||
|
resolvedConfig: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
accounts: {
|
||||||
|
work: {
|
||||||
|
botToken: "tok",
|
||||||
|
apiRoot: "https://custom.telegram.test/root/",
|
||||||
|
proxy: "http://127.0.0.1:8888",
|
||||||
|
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
|
||||||
|
allowFrom: ["@testuser"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runDoctorConfigWithInput({
|
||||||
|
repair: true,
|
||||||
|
config: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
accounts: {
|
||||||
|
work: {
|
||||||
|
botToken: "tok",
|
||||||
|
allowFrom: ["@testuser"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: loadAndMaybeMigrateDoctorConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = result.cfg as {
|
||||||
|
channels?: {
|
||||||
|
telegram?: {
|
||||||
|
accounts?: Record<string, { allowFrom?: string[] }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]);
|
||||||
|
expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8888");
|
||||||
|
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, {
|
||||||
|
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
|
||||||
|
});
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
makeProxyFetch.mockRestore();
|
||||||
|
resolveTelegramFetch.mockRestore();
|
||||||
|
resolveSecretsSpy.mockRestore();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => {
|
it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => {
|
||||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||||
const fetchSpy = vi.fn();
|
const fetchSpy = vi.fn();
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
fetchTelegramChatId,
|
|
||||||
inspectTelegramAccount,
|
inspectTelegramAccount,
|
||||||
isNumericTelegramUserId,
|
isNumericTelegramUserId,
|
||||||
listTelegramAccountIds,
|
listTelegramAccountIds,
|
||||||
|
lookupTelegramChatId,
|
||||||
normalizeTelegramAllowFromEntry,
|
normalizeTelegramAllowFromEntry,
|
||||||
} from "../../extensions/telegram/api.js";
|
} from "../../extensions/telegram/api.js";
|
||||||
import { normalizeChatChannelId } from "../channels/registry.js";
|
import { normalizeChatChannelId } from "../channels/registry.js";
|
||||||
@ -15,6 +15,7 @@ import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js";
|
|||||||
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
|
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
|
||||||
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
|
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
|
||||||
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
|
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
|
||||||
import {
|
import {
|
||||||
@ -84,6 +85,13 @@ type TelegramAllowFromListRef = {
|
|||||||
key: "allowFrom" | "groupAllowFrom";
|
key: "allowFrom" | "groupAllowFrom";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResolvedTelegramLookupAccount = {
|
||||||
|
token: string;
|
||||||
|
apiRoot?: string;
|
||||||
|
proxyUrl?: string;
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
|
};
|
||||||
|
|
||||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
@ -399,29 +407,34 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
|||||||
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
|
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
|
||||||
});
|
});
|
||||||
const tokenResolutionWarnings: string[] = [];
|
const tokenResolutionWarnings: string[] = [];
|
||||||
const tokens = Array.from(
|
const lookupAccounts: ResolvedTelegramLookupAccount[] = [];
|
||||||
new Set(
|
const seenLookupAccounts = new Set<string>();
|
||||||
listTelegramAccountIds(resolvedConfig)
|
for (const accountId of listTelegramAccountIds(resolvedConfig)) {
|
||||||
.map((accountId) => {
|
let account: NonNullable<ReturnType<typeof resolveTelegramAccount>>;
|
||||||
try {
|
try {
|
||||||
return resolveTelegramAccount({ cfg: resolvedConfig, accountId });
|
account = resolveTelegramAccount({ cfg: resolvedConfig, accountId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
tokenResolutionWarnings.push(
|
tokenResolutionWarnings.push(
|
||||||
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
|
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
|
||||||
);
|
);
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
})
|
const token = account.tokenSource === "none" ? "" : account.token.trim();
|
||||||
.filter((account): account is NonNullable<ReturnType<typeof resolveTelegramAccount>> =>
|
if (!token) {
|
||||||
Boolean(account),
|
continue;
|
||||||
)
|
}
|
||||||
.map((account) => (account.tokenSource === "none" ? "" : account.token))
|
const apiRoot = account.config.apiRoot?.trim() || undefined;
|
||||||
.map((token) => token.trim())
|
const proxyUrl = account.config.proxy?.trim() || undefined;
|
||||||
.filter(Boolean),
|
const network = account.config.network;
|
||||||
),
|
const cacheKey = `${token}::${apiRoot ?? ""}::${proxyUrl ?? ""}::${JSON.stringify(network ?? {})}`;
|
||||||
);
|
if (seenLookupAccounts.has(cacheKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenLookupAccounts.add(cacheKey);
|
||||||
|
lookupAccounts.push({ token, apiRoot, proxyUrl, network });
|
||||||
|
}
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
if (lookupAccounts.length === 0) {
|
||||||
return {
|
return {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
changes: [
|
changes: [
|
||||||
@ -449,14 +462,17 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||||
for (const token of tokens) {
|
for (const account of lookupAccounts) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 4000);
|
const timeout = setTimeout(() => controller.abort(), 4000);
|
||||||
try {
|
try {
|
||||||
const id = await fetchTelegramChatId({
|
const id = await lookupTelegramChatId({
|
||||||
token,
|
token: account.token,
|
||||||
chatId: username,
|
chatId: username,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
apiRoot: account.apiRoot,
|
||||||
|
proxyUrl: account.proxyUrl,
|
||||||
|
network: account.network,
|
||||||
});
|
});
|
||||||
if (id) {
|
if (id) {
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@ -567,6 +567,47 @@ describe("gateway-status command", () => {
|
|||||||
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
|
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the full caller timeout through to local loopback probes", async () => {
|
||||||
|
const { runtime } = createRuntimeCapture();
|
||||||
|
probeGateway.mockClear();
|
||||||
|
readBestEffortConfig.mockResolvedValueOnce({
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
auth: { mode: "token", token: "ltok" },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await runGatewayStatus(runtime, { timeout: "15000", json: true });
|
||||||
|
|
||||||
|
expect(probeGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inactive local loopback probes on the short timeout in remote mode", async () => {
|
||||||
|
const { runtime } = createRuntimeCapture();
|
||||||
|
probeGateway.mockClear();
|
||||||
|
readBestEffortConfig.mockResolvedValueOnce({
|
||||||
|
gateway: {
|
||||||
|
mode: "remote",
|
||||||
|
auth: { mode: "token", token: "ltok" },
|
||||||
|
remote: {},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await runGatewayStatus(runtime, { timeout: "15000", json: true });
|
||||||
|
|
||||||
|
expect(probeGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
timeoutMs: 800,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips invalid ssh-auto discovery targets", async () => {
|
it("skips invalid ssh-auto discovery targets", async () => {
|
||||||
const { runtime } = createRuntimeCapture();
|
const { runtime } = createRuntimeCapture();
|
||||||
await withEnvAsync({ USER: "steipete" }, async () => {
|
await withEnvAsync({ USER: "steipete" }, async () => {
|
||||||
|
|||||||
@ -176,7 +176,7 @@ export async function gatewayStatusCommand(
|
|||||||
token: authResolution.token,
|
token: authResolution.token,
|
||||||
password: authResolution.password,
|
password: authResolution.password,
|
||||||
};
|
};
|
||||||
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
|
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target);
|
||||||
const probe = await probeGateway({
|
const probe = await probeGateway({
|
||||||
url: target.url,
|
url: target.url,
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
isScopeLimitedProbeFailure,
|
isScopeLimitedProbeFailure,
|
||||||
renderProbeSummaryLine,
|
renderProbeSummaryLine,
|
||||||
resolveAuthForTarget,
|
resolveAuthForTarget,
|
||||||
|
resolveProbeBudgetMs,
|
||||||
} from "./helpers.js";
|
} from "./helpers.js";
|
||||||
|
|
||||||
describe("extractConfigSummary", () => {
|
describe("extractConfigSummary", () => {
|
||||||
@ -273,3 +274,21 @@ describe("probe reachability classification", () => {
|
|||||||
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
|
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveProbeBudgetMs", () => {
|
||||||
|
it("lets active local loopback probes use the full caller budget", () => {
|
||||||
|
expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: true })).toBe(15_000);
|
||||||
|
expect(resolveProbeBudgetMs(3_000, { kind: "localLoopback", active: true })).toBe(3_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inactive local loopback probes on the short cap", () => {
|
||||||
|
expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: false })).toBe(800);
|
||||||
|
expect(resolveProbeBudgetMs(500, { kind: "localLoopback", active: false })).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-local probe caps unchanged", () => {
|
||||||
|
expect(resolveProbeBudgetMs(15_000, { kind: "configRemote", active: true })).toBe(1_500);
|
||||||
|
expect(resolveProbeBudgetMs(15_000, { kind: "explicit", active: true })).toBe(1_500);
|
||||||
|
expect(resolveProbeBudgetMs(15_000, { kind: "sshTunnel", active: true })).toBe(2_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -116,14 +116,21 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
|
export function resolveProbeBudgetMs(
|
||||||
if (kind === "localLoopback") {
|
overallMs: number,
|
||||||
return Math.min(800, overallMs);
|
target: Pick<GatewayStatusTarget, "kind" | "active">,
|
||||||
|
): number {
|
||||||
|
switch (target.kind) {
|
||||||
|
case "localLoopback":
|
||||||
|
// Active loopback probes should honor the caller budget because local shells/containers
|
||||||
|
// can legitimately take longer to connect. Inactive loopback probes stay bounded so
|
||||||
|
// remote-mode status checks do not stall on an expected local miss.
|
||||||
|
return target.active ? overallMs : Math.min(800, overallMs);
|
||||||
|
case "sshTunnel":
|
||||||
|
return Math.min(2_000, overallMs);
|
||||||
|
default:
|
||||||
|
return Math.min(1_500, overallMs);
|
||||||
}
|
}
|
||||||
if (kind === "sshTunnel") {
|
|
||||||
return Math.min(2000, overallMs);
|
|
||||||
}
|
|
||||||
return Math.min(1500, overallMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeSshTarget(value: unknown): string | null {
|
export function sanitizeSshTarget(value: unknown): string | null {
|
||||||
|
|||||||
@ -1532,6 +1532,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
|
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
|
||||||
"channels.telegram.silentErrorReplies":
|
"channels.telegram.silentErrorReplies":
|
||||||
"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.",
|
"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.",
|
||||||
|
"channels.telegram.apiRoot":
|
||||||
|
"Custom Telegram Bot API root URL. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked.",
|
||||||
"channels.telegram.threadBindings.enabled":
|
"channels.telegram.threadBindings.enabled":
|
||||||
"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.",
|
"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.",
|
||||||
"channels.telegram.threadBindings.idleHours":
|
"channels.telegram.threadBindings.idleHours":
|
||||||
|
|||||||
@ -732,6 +732,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
|
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
|
||||||
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
||||||
"channels.telegram.silentErrorReplies": "Telegram Silent Error Replies",
|
"channels.telegram.silentErrorReplies": "Telegram Silent Error Replies",
|
||||||
|
"channels.telegram.apiRoot": "Telegram API Root URL",
|
||||||
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
||||||
"channels.telegram.execApprovals": "Telegram Exec Approvals",
|
"channels.telegram.execApprovals": "Telegram Exec Approvals",
|
||||||
"channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled",
|
"channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled",
|
||||||
|
|||||||
@ -216,6 +216,8 @@ export type TelegramAccountConfig = {
|
|||||||
* Telegram expects unicode emoji (e.g., "👀") rather than shortcodes.
|
* Telegram expects unicode emoji (e.g., "👀") rather than shortcodes.
|
||||||
*/
|
*/
|
||||||
ackReaction?: string;
|
ackReaction?: string;
|
||||||
|
/** Custom Telegram Bot API root URL (e.g. "https://my-proxy.example.com" or a local Bot API server). */
|
||||||
|
apiRoot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramTopicConfig = {
|
export type TelegramTopicConfig = {
|
||||||
|
|||||||
@ -280,6 +280,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
silentErrorReplies: z.boolean().optional(),
|
silentErrorReplies: z.boolean().optional(),
|
||||||
responsePrefix: z.string().optional(),
|
responsePrefix: z.string().optional(),
|
||||||
ackReaction: z.string().optional(),
|
ackReaction: z.string().optional(),
|
||||||
|
apiRoot: z.string().url().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@ -40,9 +40,15 @@ vi.mock("./client.js", () => ({
|
|||||||
GatewayClient: MockGatewayClient,
|
GatewayClient: MockGatewayClient,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { probeGateway } = await import("./probe.js");
|
const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js");
|
||||||
|
|
||||||
describe("probeGateway", () => {
|
describe("probeGateway", () => {
|
||||||
|
it("clamps probe timeout to timer-safe bounds", () => {
|
||||||
|
expect(clampProbeTimeoutMs(1)).toBe(250);
|
||||||
|
expect(clampProbeTimeoutMs(2_000)).toBe(2_000);
|
||||||
|
expect(clampProbeTimeoutMs(3_000_000_000)).toBe(2_147_483_647);
|
||||||
|
});
|
||||||
|
|
||||||
it("connects with operator.read scope", async () => {
|
it("connects with operator.read scope", async () => {
|
||||||
const result = await probeGateway({
|
const result = await probeGateway({
|
||||||
url: "ws://127.0.0.1:18789",
|
url: "ws://127.0.0.1:18789",
|
||||||
|
|||||||
@ -29,6 +29,13 @@ export type GatewayProbeResult = {
|
|||||||
configSnapshot: unknown;
|
configSnapshot: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MIN_PROBE_TIMEOUT_MS = 250;
|
||||||
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||||
|
|
||||||
|
export function clampProbeTimeoutMs(timeoutMs: number): number {
|
||||||
|
return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs));
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeGateway(opts: {
|
export async function probeGateway(opts: {
|
||||||
url: string;
|
url: string;
|
||||||
auth?: GatewayProbeAuth;
|
auth?: GatewayProbeAuth;
|
||||||
@ -144,21 +151,18 @@ export async function probeGateway(opts: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(() => {
|
||||||
() => {
|
settle({
|
||||||
settle({
|
ok: false,
|
||||||
ok: false,
|
connectLatencyMs,
|
||||||
connectLatencyMs,
|
error: connectError ? `connect failed: ${connectError}` : "timeout",
|
||||||
error: connectError ? `connect failed: ${connectError}` : "timeout",
|
close,
|
||||||
close,
|
health: null,
|
||||||
health: null,
|
status: null,
|
||||||
status: null,
|
presence: null,
|
||||||
presence: null,
|
configSnapshot: null,
|
||||||
configSnapshot: null,
|
});
|
||||||
});
|
}, clampProbeTimeoutMs(opts.timeoutMs));
|
||||||
},
|
|
||||||
Math.max(250, opts.timeoutMs),
|
|
||||||
);
|
|
||||||
|
|
||||||
client.start();
|
client.start();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,8 +3,10 @@ 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";
|
||||||
|
|
||||||
@ -15,6 +17,22 @@ 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();
|
||||||
@ -47,43 +65,85 @@ describe("device bootstrap tokens", () => {
|
|||||||
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)).resolves.toEqual({ 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(
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
|
||||||
verifyDeviceBootstrapToken({
|
ok: false,
|
||||||
token: issued.token,
|
reason: "bootstrap_token_invalid",
|
||||||
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(
|
||||||
verifyDeviceBootstrapToken({
|
verifyBootstrapToken(baseDir, issued.token, {
|
||||||
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" });
|
||||||
|
|
||||||
@ -95,16 +155,9 @@ describe("device bootstrap tokens", () => {
|
|||||||
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} `)).resolves.toEqual({
|
||||||
verifyDeviceBootstrapToken({
|
ok: true,
|
||||||
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("{}");
|
||||||
});
|
});
|
||||||
@ -113,16 +166,10 @@ describe("device bootstrap tokens", () => {
|
|||||||
const baseDir = await createTempDir();
|
const baseDir = await createTempDir();
|
||||||
await issueDeviceBootstrapToken({ baseDir });
|
await issueDeviceBootstrapToken({ baseDir });
|
||||||
|
|
||||||
await expect(
|
await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({
|
||||||
verifyDeviceBootstrapToken({
|
ok: false,
|
||||||
token: " ",
|
reason: "bootstrap_token_invalid",
|
||||||
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({
|
||||||
@ -179,26 +226,11 @@ describe("device bootstrap tokens", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
|
||||||
verifyDeviceBootstrapToken({
|
|
||||||
token: "legacyToken",
|
|
||||||
deviceId: "device-123",
|
|
||||||
publicKey: "public-key-123",
|
|
||||||
role: "operator.admin",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
baseDir,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
await expect(
|
await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
|
||||||
verifyDeviceBootstrapToken({
|
ok: false,
|
||||||
token: "expiredToken",
|
reason: "bootstrap_token_invalid",
|
||||||
deviceId: "device-123",
|
});
|
||||||
publicKey: "public-key-123",
|
|
||||||
role: "operator.admin",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
baseDir,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -79,6 +79,41 @@ 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;
|
||||||
@ -93,12 +128,13 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||||||
if (!providedToken) {
|
if (!providedToken) {
|
||||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||||
}
|
}
|
||||||
const entry = Object.values(state).find((candidate) =>
|
const found = Object.entries(state).find(([, candidate]) =>
|
||||||
verifyPairingToken(providedToken, candidate.token),
|
verifyPairingToken(providedToken, candidate.token),
|
||||||
);
|
);
|
||||||
if (!entry) {
|
if (!found) {
|
||||||
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();
|
||||||
@ -109,7 +145,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[entry.token];
|
delete state[tokenKey];
|
||||||
await persistState(state, params.baseDir);
|
await persistState(state, params.baseDir);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
// 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 { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
export {
|
||||||
|
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?: number;
|
messageThreadId?: string | number;
|
||||||
}): {
|
}): {
|
||||||
channel: string;
|
channel: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export {
|
|||||||
isNumericTelegramUserId,
|
isNumericTelegramUserId,
|
||||||
normalizeTelegramAllowFromEntry,
|
normalizeTelegramAllowFromEntry,
|
||||||
} from "../../../extensions/telegram/api.js";
|
} from "../../../extensions/telegram/api.js";
|
||||||
export { fetchTelegramChatId } from "../../../extensions/telegram/api.js";
|
export { fetchTelegramChatId, lookupTelegramChatId } from "../../../extensions/telegram/api.js";
|
||||||
export {
|
export {
|
||||||
resolveTelegramInlineButtonsScope,
|
resolveTelegramInlineButtonsScope,
|
||||||
resolveTelegramTargetChatType,
|
resolveTelegramTargetChatType,
|
||||||
|
|||||||
@ -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?: number;
|
messageThreadId?: string | number;
|
||||||
requestConversationBinding: (
|
requestConversationBinding: (
|
||||||
params?: PluginConversationBindingRequestParams,
|
params?: PluginConversationBindingRequestParams,
|
||||||
) => Promise<PluginConversationBindingRequestResult>;
|
) => Promise<PluginConversationBindingRequestResult>;
|
||||||
|
|||||||
@ -31,14 +31,6 @@
|
|||||||
"resolvedPath": "extensions/imessage/runtime-api.js",
|
"resolvedPath": "extensions/imessage/runtime-api.js",
|
||||||
"reason": "imports extension-owned file from src/plugins"
|
"reason": "imports extension-owned file from src/plugins"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/plugins/runtime/runtime-matrix.ts",
|
|
||||||
"line": 4,
|
|
||||||
"kind": "import",
|
|
||||||
"specifier": "../../../extensions/matrix/runtime-api.js",
|
|
||||||
"resolvedPath": "extensions/matrix/runtime-api.js",
|
|
||||||
"reason": "imports extension-owned file from src/plugins"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user