Compare commits

..

2 Commits

Author SHA1 Message Date
Tyler Yust
b36f8d23e9 Agents: harden CLI prompt image hydration (#51373) 2026-03-20 19:29:35 -07:00
Tyler Yust
315713fc40 fix(cli): hydrate prompt image refs for inbound media 2026-03-20 18:58:15 -07:00
71 changed files with 719 additions and 2352 deletions

View File

@ -54,7 +54,6 @@ Docs: https://docs.openclaw.ai
- 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: 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
@ -192,9 +191,6 @@ 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.
- 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.
- 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
@ -209,6 +205,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaws local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow.
- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
- BlueBubbles/CLI agents: restore inbound prompt image refs for CLI routed turns, reapply embedded runner image size guardrails, and cover both CLI image transport paths with regression tests. (#51373)
## 2026.3.13

View File

@ -174,12 +174,7 @@ final class GatewayConnectionController {
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if resolvedUseTLS, stored == nil {
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) 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
}
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
self.pendingTrustPrompt = TrustPrompt(
stableID: stableID,

View File

@ -607,7 +607,7 @@ struct OnboardingWizardView: View {
private var authStep: some View {
Group {
Section("Authentication") {
SecureField("Gateway Auth Token", text: self.$gatewayToken)
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
@ -724,12 +724,6 @@ struct OnboardingWizardView: View {
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if self.selectedMode == .remoteDomain {
SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
}
self.manualConnectButton
}
}

View File

@ -289,17 +289,6 @@ public final class OpenClawChatViewModel {
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? {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !role.isEmpty else { return nil }
@ -309,7 +298,15 @@ public final class OpenClawChatViewModel {
return String(format: "%.3f", value)
}()
let contentFingerprint = Self.messageContentFingerprint(for: message)
let contentFingerprint = message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
@ -318,19 +315,6 @@ public final class OpenClawChatViewModel {
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
}
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(
previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
@ -369,75 +353,6 @@ public final class OpenClawChatViewModel {
}
}
private static func reconcileRunRefreshMessages(
previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
{
guard !previous.isEmpty else { return incoming }
guard !incoming.isEmpty else { return previous }
func countKeys(_ keys: [String]) -> [String: Int] {
keys.reduce(into: [:]) { counts, key in
counts[key, default: 0] += 1
}
}
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
var remainingIncomingUserRefreshCounts = countKeys(
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
var lastMatchedPreviousIndex: Int?
for (index, message) in previous.enumerated() {
if let key = Self.messageIdentityKey(for: message),
incomingIdentityKeys.contains(key)
{
lastMatchedPreviousIndex = index
continue
}
if let userKey = Self.userRefreshIdentityKey(for: message),
let remaining = remainingIncomingUserRefreshCounts[userKey],
remaining > 0
{
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
lastMatchedPreviousIndex = index
}
}
let trailingUserMessages = (lastMatchedPreviousIndex != nil
? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!))
: ArraySlice(previous))
.filter { message in
guard message.role.lowercased() == "user" else { return false }
guard let key = Self.userRefreshIdentityKey(for: message) else { return false }
let remaining = remainingIncomingUserRefreshCounts[key] ?? 0
if remaining > 0 {
remainingIncomingUserRefreshCounts[key] = remaining - 1
return false
}
return true
}
guard !trailingUserMessages.isEmpty else {
return reconciled
}
for message in trailingUserMessages {
guard let messageTimestamp = message.timestamp else {
reconciled.append(message)
continue
}
let insertIndex = reconciled.firstIndex { existing in
guard let existingTimestamp = existing.timestamp else { return false }
return existingTimestamp > messageTimestamp
} ?? reconciled.endIndex
reconciled.insert(message, at: insertIndex)
}
return Self.dedupeMessages(reconciled)
}
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
var result: [OpenClawChatMessage] = []
result.reserveCapacity(messages.count)
@ -1004,7 +919,7 @@ public final class OpenClawChatViewModel {
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.reconcileRunRefreshMessages(
self.messages = Self.reconcileMessageIDs(
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId

View File

@ -513,11 +513,8 @@ public actor GatewayChannelActor {
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
let authToken =
explicitToken ??
// A freshly scanned setup code should force the bootstrap pairing path instead of
// silently reusing an older stored device token.
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken
: nil)
(includeDeviceIdentity && explicitPassword == nil &&
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource

View File

@ -126,28 +126,6 @@ private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") a
}
}
@discardableResult
private func sendMessageAndEmitFinal(
transport: TestChatTransport,
vm: OpenClawChatViewModel,
text: String,
sessionKey: String = "main") async throws -> String
{
await sendUserMessage(vm, text: text)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: sessionKey,
state: "final",
message: nil,
errorMessage: nil)))
return runId
}
private func emitAssistantText(
transport: TestChatTransport,
runId: String,
@ -461,141 +439,6 @@ extension TestChatTransportState {
#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 {
let history1 = historyPayload()
let history2 = historyPayload(

View File

@ -15,7 +15,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private let lock = NSLock()
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@ -51,18 +50,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
self.lock.withLock {
self.connectRequestId = id
self.connectAuth = auth
}
self.lock.withLock { self.connectRequestId = id }
}
}
func latestConnectAuth() -> [String: Any]? {
self.lock.withLock { self.connectAuth }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@ -178,62 +169,6 @@ private actor SeqGapProbe {
}
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
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(

View File

@ -1,14 +1,8 @@
export {
approveDevicePairing,
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken,
listDevicePairing,
revokeDeviceBootstrapToken,
} from "openclaw/plugin-sdk/device-bootstrap";
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
export {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
} from "openclaw/plugin-sdk/sandbox";
export { renderQrPngBase64 } from "./qr-image.js";
export { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox";

View File

@ -1,359 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
const pluginApiMocks = vi.hoisted(() => ({
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
issueDeviceBootstrapToken: vi.fn(async () => ({
token: "boot-token",
expiresAtMs: Date.now() + 10 * 60_000,
})),
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="),
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
}));
vi.mock("./api.js", () => {
return {
approveDevicePairing: vi.fn(),
clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens,
definePluginEntry: vi.fn((entry) => entry),
issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken,
listDevicePairing: vi.fn(async () => ({ pending: [] })),
renderQrPngBase64: pluginApiMocks.renderQrPngBase64,
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
resolveGatewayBindUrl: vi.fn(),
resolveTailnetHostWithRunner: vi.fn(),
runPluginCommandWithTimeout: vi.fn(),
};
});
vi.mock("./notify.js", () => ({
armPairNotifyOnce: vi.fn(async () => false),
formatPendingRequests: vi.fn(() => "No pending device pairing requests."),
handleNotifyCommand: vi.fn(async () => ({ text: "notify" })),
registerPairingNotifierService: vi.fn(),
}));
import registerDevicePair from "./index.js";
function createApi(params?: {
runtime?: OpenClawPluginApi["runtime"];
pluginConfig?: Record<string, unknown>;
registerCommand?: (command: OpenClawPluginCommandDefinition) => void;
}): OpenClawPluginApi {
return createTestPluginApi({
id: "device-pair",
name: "device-pair",
source: "test",
config: {
gateway: {
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: "ws://51.79.175.165:18789",
...(params?.pluginConfig ?? {}),
},
runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"],
registerCommand: params?.registerCommand,
}) as OpenClawPluginApi;
}
function registerPairCommand(params?: {
runtime?: OpenClawPluginApi["runtime"];
pluginConfig?: Record<string, unknown>;
}): OpenClawPluginCommandDefinition {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
...params,
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
expect(command).toBeTruthy();
return command!;
}
function createChannelRuntime(
runtimeKey: string,
sendKey: string,
sendMessage: (...args: unknown[]) => Promise<unknown>,
): OpenClawPluginApi["runtime"] {
return {
channel: {
[runtimeKey]: {
[sendKey]: sendMessage,
},
},
} as unknown as OpenClawPluginApi["runtime"];
}
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
return {
channel: "webchat",
isAuthorizedSender: true,
commandBody: "/pair qr",
args: "qr",
config: {},
requestConversationBinding: async () => ({
status: "error",
message: "unsupported",
}),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
...params,
};
}
describe("device-pair /pair qr", () => {
beforeEach(async () => {
vi.clearAllMocks();
pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({
token: "boot-token",
expiresAtMs: Date.now() + 10 * 60_000,
});
await fs.mkdir(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true });
});
afterEach(async () => {
await fs.rm(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true, force: true });
});
it("returns an inline QR image for webchat surfaces", async () => {
const command = registerPairCommand();
const result = await command?.handler(createCommandContext({ channel: "webchat" }));
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
expect(result?.text).toContain("Scan this QR code with the OpenClaw iOS app:");
expect(result?.text).toContain("![OpenClaw pairing QR](data:image/png;base64,ZmFrZXBuZw==)");
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." });
});
});

View File

@ -1,18 +1,13 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import qrcode from "qrcode-terminal";
import {
approveDevicePairing,
clearDeviceBootstrapTokens,
definePluginEntry,
issueDeviceBootstrapToken,
listDevicePairing,
renderQrPngBase64,
revokeDeviceBootstrapToken,
resolveGatewayBindUrl,
resolvePreferredOpenClawTmpDir,
resolveTailnetHostWithRunner,
runPluginCommandWithTimeout,
resolveTailnetHostWithRunner,
type OpenClawPluginApi,
} from "./api.js";
import {
@ -22,24 +17,12 @@ import {
registerPairingNotifierService,
} from "./notify.js";
async function renderQrDataUrl(data: string): Promise<string> {
const pngBase64 = await renderQrPngBase64(data);
return `data:image/png;base64,${pngBase64}`;
}
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"}`;
function renderQrAscii(data: string): Promise<string> {
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
const DEFAULT_GATEWAY_PORT = 18789;
@ -51,7 +34,6 @@ type DevicePairPluginConfig = {
type SetupPayload = {
url: string;
bootstrapToken: string;
expiresAtMs: number;
};
type ResolveUrlResult = {
@ -65,85 +47,6 @@ type ResolveAuthLabelResult = {
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 {
const candidate = raw.trim();
if (!candidate) {
@ -396,172 +299,33 @@ function encodeSetupCode(payload: SetupPayload): string {
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.",
"Ill 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 {
const setupCode = encodeSetupCode(payload);
return [
"Pairing setup code generated.",
"",
...buildPairingFlowLines("Paste the setup code below and tap Connect"),
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code below and tap Connect",
"3) Back here, run /pair approve",
"",
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs: payload.expiresAtMs,
}),
].join("\n");
}
function formatSetupInstructions(expiresAtMs: number): string {
function formatSetupInstructions(): string {
return [
"Pairing setup code generated.",
"",
...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
"",
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs,
}),
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code from my next message and tap Connect",
"3) Back here, run /pair approve",
].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 wont 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 wont 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({
id: "device-pair",
name: "Device Pair",
@ -636,16 +400,6 @@ export default definePluginEntry({
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);
if (authLabelResult.error) {
return { text: `Error: ${authLabelResult.error}` };
@ -655,11 +409,19 @@ export default definePluginEntry({
if (!urlResult.url) {
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") {
const setupCode = encodeSetupCode(payload);
const qrAscii = await renderQrAscii(setupCode);
const authLabel = authLabelResult.label ?? "auth";
const channel = ctx.channel;
const target = resolveQrReplyTarget(ctx);
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
let autoNotifyArmed = false;
if (channel === "telegram" && target) {
@ -674,99 +436,82 @@ export default definePluginEntry({
}
}
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;
if (channel === "telegram" && target) {
try {
qrFilePath = await writeQrPngTempFile(setupCode);
const sent = await sendQrPngToSupportedChannel({
api,
ctx,
target,
caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
"\n",
),
qrFilePath,
});
if (sent) {
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (send) {
await send(
target,
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
"\n",
),
{
...(ctx.messageThreadId != null
? { messageThreadId: ctx.messageThreadId }
: {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
},
);
return {
text:
`QR code sent above.\n` +
`Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` +
"IMPORTANT: Run /pair cleanup after pairing finishes.",
text: [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
autoNotifyArmed
? "After scanning, wait here for the pairing request ping."
: "After scanning, come back here and run `/pair approve` to complete pairing.",
...(autoNotifyArmed
? [
"Ill 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) {
api.logger.warn?.(
`device-pair: QR image send failed channel=${channel}, falling back (${String(
`device-pair: telegram QR send failed, falling back (${String(
(err as Error)?.message ?? err,
)})`,
);
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}`);
if (channel === "webchat") {
let qrDataUrl: string;
try {
qrDataUrl = await renderQrDataUrl(setupCode);
} catch (err) {
api.logger.warn?.(
`device-pair: webchat QR render failed, falling back (${String(
(err as Error)?.message ?? err,
)})`,
);
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,
}),
"",
`![OpenClaw pairing QR](${qrDataUrl})`,
].join("\n"),
};
}
const infoLines = [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
autoNotifyArmed
? "After scanning, wait here for the pairing request ping."
: "After scanning, run `/pair approve` to complete pairing.",
...(autoNotifyArmed
? [
"Ill auto-ping here when the pairing request arrives, then auto-disable.",
"If the ping does not arrive, run `/pair approve latest` manually.",
]
: []),
];
// WebUI + CLI/TUI: ASCII QR
return {
text:
"QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" +
formatSetupReply(payload, authLabel),
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
"```",
qrAscii,
"```",
"",
...infoLines,
].join("\n"),
};
}
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const payload = await issueSetupPayload(urlResult.url);
const authLabel = authLabelResult.label ?? "auth";
if (channel === "telegram" && target) {
try {
@ -785,10 +530,8 @@ export default definePluginEntry({
)})`,
);
}
await send(target, formatSetupInstructions(payload.expiresAtMs), {
...(typeof ctx.messageThreadId === "number"
? { messageThreadId: ctx.messageThreadId }
: {}),
await send(target, formatSetupInstructions(), {
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
});
api.logger.info?.(
@ -805,6 +548,7 @@ export default definePluginEntry({
);
}
}
return {
text: formatSetupReply(payload, authLabel),
};

View File

@ -10,7 +10,7 @@ const NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
type NotifySubscription = {
to: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
mode: "persistent" | "once";
addedAtMs: number;
};
@ -101,11 +101,9 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile {
? record.accountId.trim()
: undefined;
const messageThreadId =
typeof record.messageThreadId === "string"
? record.messageThreadId.trim() || undefined
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
? Math.trunc(record.messageThreadId)
: undefined;
typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
? Math.trunc(record.messageThreadId)
: undefined;
const mode = record.mode === "once" ? "once" : "persistent";
const addedAtMs =
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
@ -152,7 +150,7 @@ async function writeNotifyState(filePath: string, state: NotifyStateFile): Promi
function notifySubscriberKey(subscriber: {
to: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
}): string {
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
}
@ -160,7 +158,7 @@ function notifySubscriberKey(subscriber: {
type NotifyTarget = {
to: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
};
function resolveNotifyTarget(ctx: {
@ -168,7 +166,7 @@ function resolveNotifyTarget(ctx: {
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
}): NotifyTarget | null {
const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (!to) {
@ -263,7 +261,7 @@ async function notifySubscriber(params: {
try {
await send(params.subscriber.to, params.text, {
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
...(typeof params.subscriber.messageThreadId === "number"
...(params.subscriber.messageThreadId != null
? { messageThreadId: params.subscriber.messageThreadId }
: {}),
});
@ -349,7 +347,7 @@ export async function armPairNotifyOnce(params: {
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
};
}): Promise<boolean> {
if (params.ctx.channel !== "telegram") {
@ -383,7 +381,7 @@ export async function handleNotifyCommand(params: {
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
};
action: string;
}): Promise<{ text: string }> {

View File

@ -1,54 +0,0 @@
import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime";
import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js";
import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js";
type QRCodeConstructor = new (
typeNumber: number,
errorCorrectLevel: unknown,
) => {
addData: (data: string) => void;
make: () => void;
getModuleCount: () => number;
isDark: (row: number, col: number) => boolean;
};
const QRCode = QRCodeModule as QRCodeConstructor;
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
function createQrMatrix(input: string) {
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
export async function renderQrPngBase64(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) {
continue;
}
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return png.toString("base64");
}

View File

@ -92,7 +92,6 @@ import { resolveDiscordPresenceUpdate } from "./presence.js";
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
import { resolveDiscordRestFetch } from "./rest-fetch.js";
import { formatDiscordStartupStatusMessage } from "./startup-status.js";
import type { DiscordMonitorStatusSink } from "./status.js";
import {
createNoopThreadBindingManager,
@ -973,12 +972,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const botIdentity =
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
runtime.log?.(
formatDiscordStartupStatusMessage({
gatewayReady: lifecycleGateway?.isConnected === true,
botIdentity: botIdentity || undefined,
}),
);
runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`);
if (lifecycleGateway?.isConnected) {
opts.setStatus?.(createConnectedChannelStatusPatch());
}

View File

@ -1,30 +0,0 @@
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");
});
});

View File

@ -1,10 +0,0 @@
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`;
}

View File

@ -51,18 +51,16 @@ type FeishuThreadBindingsState = {
};
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
let state: FeishuThreadBindingsState | undefined;
const state = resolveGlobalSingleton<FeishuThreadBindingsState>(
FEISHU_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map(),
bindingsByAccountConversation: new Map(),
}),
);
function getState(): FeishuThreadBindingsState {
state ??= resolveGlobalSingleton<FeishuThreadBindingsState>(
FEISHU_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map(),
bindingsByAccountConversation: new Map(),
}),
);
return state;
}
const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId;
const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation;
function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
return `${params.accountId}:${params.conversationId}`;
@ -121,7 +119,7 @@ export function createFeishuThreadBindingManager(params: {
cfg: OpenClawConfig;
}): FeishuThreadBindingManager {
const accountId = normalizeAccountId(params.accountId);
const existing = getState().managersByAccountId.get(accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing) {
return existing;
}
@ -140,11 +138,9 @@ export function createFeishuThreadBindingManager(params: {
const manager: FeishuThreadBindingManager = {
accountId,
getByConversationId: (conversationId) =>
getState().bindingsByAccountConversation.get(
resolveBindingKey({ accountId, conversationId }),
),
BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })),
listBySessionKey: (targetSessionKey) =>
[...getState().bindingsByAccountConversation.values()].filter(
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
),
bindConversation: ({
@ -188,7 +184,7 @@ export function createFeishuThreadBindingManager(params: {
boundAt: now,
lastActivityAt: now,
};
getState().bindingsByAccountConversation.set(
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
record,
);
@ -196,30 +192,30 @@ export function createFeishuThreadBindingManager(params: {
},
touchConversation: (conversationId, at = Date.now()) => {
const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = getState().bindingsByAccountConversation.get(key);
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
if (!existingRecord) {
return null;
}
const updated = { ...existingRecord, lastActivityAt: at };
getState().bindingsByAccountConversation.set(key, updated);
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated);
return updated;
},
unbindConversation: (conversationId) => {
const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = getState().bindingsByAccountConversation.get(key);
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
if (!existingRecord) {
return null;
}
getState().bindingsByAccountConversation.delete(key);
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
return existingRecord;
},
unbindBySessionKey: (targetSessionKey) => {
const removed: FeishuThreadBindingRecord[] = [];
for (const record of [...getState().bindingsByAccountConversation.values()]) {
for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) {
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
continue;
}
getState().bindingsByAccountConversation.delete(
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(
resolveBindingKey({ accountId, conversationId: record.conversationId }),
);
removed.push(record);
@ -227,12 +223,12 @@ export function createFeishuThreadBindingManager(params: {
return removed;
},
stop: () => {
for (const key of [...getState().bindingsByAccountConversation.keys()]) {
for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) {
if (key.startsWith(`${accountId}:`)) {
getState().bindingsByAccountConversation.delete(key);
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
}
}
getState().managersByAccountId.delete(accountId);
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
},
};
@ -294,22 +290,22 @@ export function createFeishuThreadBindingManager(params: {
},
});
getState().managersByAccountId.set(accountId, manager);
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
return manager;
}
export function getFeishuThreadBindingManager(
accountId?: string,
): FeishuThreadBindingManager | null {
return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
}
export const __testing = {
resetFeishuThreadBindingsForTests() {
for (const manager of getState().managersByAccountId.values()) {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
getState().managersByAccountId.clear();
getState().bindingsByAccountConversation.clear();
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
},
};

View File

@ -15,12 +15,7 @@ const MAX_ENTRIES = 5000;
*/
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
let threadParticipation: Map<string, number> | undefined;
function getThreadParticipation(): Map<string, number> {
threadParticipation ??= resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
return threadParticipation;
}
const threadParticipation = resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
function makeKey(accountId: string, channelId: string, threadTs: string): string {
return `${accountId}:${channelId}:${threadTs}`;
@ -28,17 +23,17 @@ function makeKey(accountId: string, channelId: string, threadTs: string): string
function evictExpired(): void {
const now = Date.now();
for (const [key, timestamp] of getThreadParticipation()) {
for (const [key, timestamp] of threadParticipation) {
if (now - timestamp > TTL_MS) {
getThreadParticipation().delete(key);
threadParticipation.delete(key);
}
}
}
function evictOldest(): void {
const oldest = getThreadParticipation().keys().next().value;
const oldest = threadParticipation.keys().next().value;
if (oldest) {
getThreadParticipation().delete(oldest);
threadParticipation.delete(oldest);
}
}
@ -50,7 +45,6 @@ export function recordSlackThreadParticipation(
if (!accountId || !channelId || !threadTs) {
return;
}
const threadParticipation = getThreadParticipation();
if (threadParticipation.size >= MAX_ENTRIES) {
evictExpired();
}
@ -69,7 +63,6 @@ export function hasSlackThreadParticipation(
return false;
}
const key = makeKey(accountId, channelId, threadTs);
const threadParticipation = getThreadParticipation();
const timestamp = threadParticipation.get(key);
if (timestamp == null) {
return false;
@ -82,5 +75,5 @@ export function hasSlackThreadParticipation(
}
export function clearSlackThreadParticipationCache(): void {
getThreadParticipation().clear();
threadParticipation.clear();
}

View File

@ -54,28 +54,4 @@ describe("fetchTelegramChatId", () => {
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,
);
});
});

View File

@ -1,48 +1,11 @@
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: {
token: string;
chatId: string;
signal?: AbortSignal;
apiRoot?: string;
fetchImpl?: typeof fetch;
}): Promise<string | null> {
const apiBase = resolveTelegramApiBase(params.apiRoot);
const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
const fetchImpl = params.fetchImpl ?? fetch;
const url = `https://api.telegram.org/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
try {
const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined);
const res = await fetch(url, params.signal ? { signal: params.signal } : undefined);
if (!res.ok) {
return null;
}

View File

@ -5,9 +5,11 @@ import type {
TelegramGroupMembershipAudit,
TelegramGroupMembershipAuditEntry,
} from "./audit.js";
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
const TELEGRAM_API_BASE = "https://api.telegram.org";
type TelegramApiOk<T> = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
@ -16,11 +18,8 @@ export async function auditTelegramGroupMembershipImpl(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAuditData> {
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
const fetcher = resolveTelegramFetch(proxyFetch, {
network: params.network,
});
const apiBase = resolveTelegramApiBase(params.apiRoot);
const base = `${apiBase}/bot${params.token}`;
const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network });
const base = `${TELEGRAM_API_BASE}/bot${params.token}`;
const groups: TelegramGroupMembershipAuditEntry[] = [];
for (const chatId of params.groupIds) {

View File

@ -66,7 +66,6 @@ export type AuditTelegramGroupMembershipParams = {
groupIds: string[];
proxyUrl?: string;
network?: TelegramNetworkConfig;
apiRoot?: string;
timeoutMs: number;
};

View File

@ -361,13 +361,7 @@ export const registerTelegramHandlers = ({
for (const { ctx } of entry.messages) {
let media;
try {
media = await resolveMedia(
ctx,
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg.apiRoot,
);
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;
@ -472,7 +466,6 @@ export const registerTelegramHandlers = ({
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg.apiRoot,
);
if (!media) {
return [];
@ -984,13 +977,7 @@ export const registerTelegramHandlers = ({
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
try {
media = await resolveMedia(
ctx,
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg.apiRoot,
);
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
} catch (mediaErr) {
if (isMediaSizeLimitError(mediaErr)) {
if (sendOversizeWarning) {

View File

@ -230,13 +230,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
: undefined;
const apiRoot = telegramCfg.apiRoot?.trim() || undefined;
const client: ApiClientOptions | undefined =
finalFetch || timeoutSeconds || apiRoot
finalFetch || timeoutSeconds
? {
...(finalFetch ? { fetch: finalFetch } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
...(apiRoot ? { apiRoot } : {}),
}
: undefined;

View File

@ -360,38 +360,6 @@ 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", () => {

View File

@ -1,39 +1,21 @@
import path from "node:path";
import { GrammyError } from "grammy";
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import { retryAsync } from "openclaw/plugin-sdk/infra-runtime";
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import {
resolveTelegramApiBase,
shouldRetryTelegramTransportFallback,
type TelegramTransport,
} from "../fetch.js";
import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js";
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
import type { StickerMetadata, TelegramContext } from "./types.js";
const FILE_TOO_BIG_RE = /file is too big/i;
function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
const hostnames = ["api.telegram.org"];
if (apiRoot) {
try {
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,
};
}
const TELEGRAM_MEDIA_SSRF_POLICY = {
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
// resolution maps to private/internal ranges in restricted networks.
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
};
/**
* Returns true if the error is Telegram's "file is too big" error.
@ -142,13 +124,8 @@ async function downloadAndSaveTelegramFile(params: {
transport: TelegramTransport;
maxBytes: number;
telegramFileName?: string;
apiRoot?: string;
}) {
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 url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`;
const fetched = await fetchRemoteMedia({
url,
fetchImpl: params.transport.sourceFetch,
@ -157,7 +134,7 @@ async function downloadAndSaveTelegramFile(params: {
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot),
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
});
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer(
@ -175,7 +152,6 @@ async function resolveStickerMedia(params: {
maxBytes: number;
token: string;
transport?: TelegramTransport;
apiRoot?: string;
}): Promise<
| {
path: string;
@ -216,7 +192,6 @@ async function resolveStickerMedia(params: {
token,
transport: resolvedTransport,
maxBytes,
apiRoot: params.apiRoot,
});
// Check sticker cache for existing description
@ -272,7 +247,6 @@ export async function resolveMedia(
maxBytes: number,
token: string,
transport?: TelegramTransport,
apiRoot?: string,
): Promise<{
path: string;
contentType?: string;
@ -286,7 +260,6 @@ export async function resolveMedia(
maxBytes,
token,
transport,
apiRoot,
});
if (stickerResolved !== undefined) {
return stickerResolved;
@ -310,7 +283,6 @@ export async function resolveMedia(
transport: resolveRequiredTelegramTransport(transport),
maxBytes,
telegramFileName: resolveTelegramFileName(msg),
apiRoot,
});
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
return { path: saved.path, contentType: saved.contentType, placeholder };

View File

@ -586,7 +586,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
apiRoot: account.config.apiRoot,
}),
formatCapabilitiesProbe: ({ probe }) => {
const lines = [];
@ -638,7 +637,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
groupIds,
proxyUrl: account.config.proxy,
network: account.config.network,
apiRoot: account.config.apiRoot,
timeoutMs,
});
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
@ -706,7 +704,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
apiRoot: account.config.apiRoot,
});
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) {

View File

@ -28,17 +28,11 @@ type TelegramSendMessageDraft = (
*/
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
let draftStreamState: { nextDraftId: number } | undefined;
function getDraftStreamState(): { nextDraftId: number } {
draftStreamState ??= resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
nextDraftId: 0,
}));
return draftStreamState;
}
const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
nextDraftId: 0,
}));
function allocateTelegramDraftId(): number {
const draftStreamState = getDraftStreamState();
draftStreamState.nextDraftId =
draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1;
return draftStreamState.nextDraftId;
@ -460,6 +454,6 @@ export function createTelegramDraftStream(params: {
export const __testing = {
resetTelegramDraftStreamForTests() {
getDraftStreamState().nextDraftId = 0;
draftStreamState.nextDraftId = 0;
},
};

View File

@ -589,12 +589,3 @@ export function resolveTelegramFetch(
): typeof 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}`;
}

View File

@ -103,34 +103,17 @@ function escapeRegex(str: string): string {
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 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;
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 {
if (filename.startsWith("//")) {
@ -151,8 +134,8 @@ function wrapSegmentFileRefs(
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
return text;
}
const wrappedStandalone = text.replace(getFileReferencePattern(), wrapStandaloneFileRef);
return wrappedStandalone.replace(getOrphanedTldPattern(), (match, prefix: string, tld: string) =>
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
);
}

View File

@ -7,8 +7,6 @@ const makeProxyFetch = vi.hoisted(() => vi.fn());
vi.mock("./fetch.js", () => ({
resolveTelegramFetch,
resolveTelegramApiBase: (apiRoot?: string) =>
apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org",
}));
vi.mock("./proxy.js", () => ({
@ -192,7 +190,6 @@ describe("probeTelegram retry logic", () => {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
apiRoot: undefined,
});
});

View File

@ -1,9 +1,11 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
import type { TelegramNetworkConfig } from "../runtime-api.js";
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
const TELEGRAM_API_BASE = "https://api.telegram.org";
export type TelegramProbe = BaseProbeResult & {
status?: number | null;
elapsedMs: number;
@ -21,7 +23,6 @@ export type TelegramProbeOptions = {
proxyUrl?: string;
network?: TelegramNetworkConfig;
accountId?: string;
apiRoot?: string;
};
const probeFetcherCache = new Map<string, typeof fetch>();
@ -55,8 +56,7 @@ function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions
const autoSelectFamilyKey =
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default";
const apiRootKey = options?.apiRoot?.trim() ?? "";
return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}`;
return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`;
}
function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch {
@ -82,9 +82,7 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ
const proxyUrl = options?.proxyUrl?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
const resolved = resolveTelegramFetch(proxyFetch, {
network: options?.network,
});
const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network });
if (cacheKey) {
return setCachedProbeFetcher(cacheKey, resolved);
@ -102,8 +100,7 @@ export async function probeTelegram(
const deadlineMs = started + timeoutBudgetMs;
const options = resolveProbeOptions(proxyOrOptions);
const fetcher = resolveProbeFetcher(token, options);
const apiBase = resolveTelegramApiBase(options?.apiRoot);
const base = `${apiBase}/bot${token}`;
const base = `${TELEGRAM_API_BASE}/bot${token}`;
const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5)));
const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now());

View File

@ -37,8 +37,6 @@ vi.mock("./proxy.js", () => ({
vi.mock("./fetch.js", () => ({
resolveTelegramFetch,
resolveTelegramApiBase: (apiRoot?: string) =>
apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org",
}));
vi.mock("grammy", () => ({

View File

@ -25,7 +25,7 @@ import { withTelegramApiErrorLogging } from "./api-logging.js";
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { splitTelegramCaption } from "./caption.js";
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { resolveTelegramFetch } from "./fetch.js";
import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js";
import {
isRecoverableTelegramNetworkError,
@ -192,10 +192,9 @@ function buildTelegramClientOptionsCacheKey(params: {
const autoSelectFamilyKey =
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default";
const apiRootKey = params.account.config.apiRoot?.trim() ?? "";
const timeoutSecondsKey =
typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default";
return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}::${timeoutSecondsKey}`;
return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`;
}
function setCachedTelegramClientOptions(
@ -234,16 +233,14 @@ function resolveTelegramClientOptions(
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
const apiRoot = account.config.apiRoot?.trim() || undefined;
const fetchImpl = resolveTelegramFetch(proxyFetch, {
network: account.config.network,
});
const clientOptions =
fetchImpl || timeoutSeconds || apiRoot
fetchImpl || timeoutSeconds
? {
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
...(apiRoot ? { apiRoot } : {}),
}
: undefined;
if (cacheKey) {

View File

@ -17,12 +17,7 @@ type CacheEntry = {
*/
const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages");
let sentMessages: Map<string, CacheEntry> | undefined;
function getSentMessages(): Map<string, CacheEntry> {
sentMessages ??= resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY);
return sentMessages;
}
const sentMessages = resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY);
function getChatKey(chatId: number | string): string {
return String(chatId);
@ -42,7 +37,6 @@ function cleanupExpired(entry: CacheEntry): void {
*/
export function recordSentMessage(chatId: number | string, messageId: number): void {
const key = getChatKey(chatId);
const sentMessages = getSentMessages();
let entry = sentMessages.get(key);
if (!entry) {
entry = { timestamps: new Map() };
@ -60,7 +54,7 @@ export function recordSentMessage(chatId: number | string, messageId: number): v
*/
export function wasSentByBot(chatId: number | string, messageId: number): boolean {
const key = getChatKey(chatId);
const entry = getSentMessages().get(key);
const entry = sentMessages.get(key);
if (!entry) {
return false;
}
@ -73,5 +67,5 @@ export function wasSentByBot(chatId: number | string, messageId: number): boolea
* Clear all cached entries (for testing).
*/
export function clearSentMessageCache(): void {
getSentMessages().clear();
sentMessages.clear();
}

View File

@ -1,46 +0,0 @@
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();
}
});
});

View File

@ -9,9 +9,8 @@ import {
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import type { TelegramNetworkConfig } from "../runtime-api.js";
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
import { lookupTelegramChatId } from "./api-fetch.js";
import { fetchTelegramChatId } from "./api-fetch.js";
const channel = "telegram" as const;
@ -47,9 +46,6 @@ export function parseTelegramAllowFromId(raw: string): string | null {
export async function resolveTelegramAllowFromEntries(params: {
entries: string[];
credentialValue?: string;
apiRoot?: string;
proxyUrl?: string;
network?: TelegramNetworkConfig;
}) {
return await Promise.all(
params.entries.map(async (entry) => {
@ -62,12 +58,9 @@ export async function resolveTelegramAllowFromEntries(params: {
return { input: entry, resolved: false, id: null };
}
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const id = await lookupTelegramChatId({
const id = await fetchTelegramChatId({
token: params.credentialValue,
chatId: username,
apiRoot: params.apiRoot,
proxyUrl: params.proxyUrl,
network: params.network,
});
return { input: entry, resolved: Boolean(id), id };
}),
@ -103,9 +96,6 @@ export async function promptTelegramAllowFromForAccount(params: {
resolveTelegramAllowFromEntries({
credentialValue: token,
entries,
apiRoot: resolved.config.apiRoot,
proxyUrl: resolved.config.proxy,
network: resolved.config.network,
}),
});
return patchChannelConfigForAccount({

View File

@ -119,11 +119,10 @@ export const telegramSetupWizard: ChannelSetupWizard = {
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
parseInputs: splitSetupEntries,
parseId: parseTelegramAllowFromId,
resolveEntries: async ({ cfg, accountId, credentialValues, entries }) =>
resolveEntries: async ({ credentialValues, entries }) =>
resolveTelegramAllowFromEntries({
credentialValue: credentialValues.token,
entries,
apiRoot: resolveTelegramAccount({ cfg, accountId }).config.apiRoot,
}),
apply: async ({ cfg, accountId, allowFrom }) =>
patchChannelConfigForAccount({

View File

@ -77,19 +77,17 @@ type TelegramThreadBindingsState = {
*/
const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState");
let threadBindingsState: TelegramThreadBindingsState | undefined;
function getThreadBindingsState(): TelegramThreadBindingsState {
threadBindingsState ??= resolveGlobalSingleton<TelegramThreadBindingsState>(
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
persistQueueByAccountId: new Map<string, Promise<void>>(),
}),
);
return threadBindingsState;
}
const threadBindingsState = resolveGlobalSingleton<TelegramThreadBindingsState>(
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
persistQueueByAccountId: new Map<string, Promise<void>>(),
}),
);
const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId;
const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation;
const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId;
function normalizeDurationMs(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
@ -170,7 +168,7 @@ function fromSessionBindingInput(params: {
}): TelegramThreadBindingRecord {
const now = Date.now();
const metadata = params.input.metadata ?? {};
const existing = getThreadBindingsState().bindingsByAccountConversation.get(
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(
resolveBindingKey({
accountId: params.accountId,
conversationId: params.input.conversationId,
@ -312,7 +310,7 @@ async function persistBindingsToDisk(params: {
version: STORE_VERSION,
bindings:
params.bindings ??
[...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === params.accountId,
),
};
@ -324,7 +322,7 @@ async function persistBindingsToDisk(params: {
}
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
return [...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === accountId,
);
}
@ -337,17 +335,16 @@ function enqueuePersistBindings(params: {
if (!params.persist) {
return Promise.resolve();
}
const previous =
getThreadBindingsState().persistQueueByAccountId.get(params.accountId) ?? Promise.resolve();
const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => {
await persistBindingsToDisk(params);
});
getThreadBindingsState().persistQueueByAccountId.set(params.accountId, next);
PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next);
void next.finally(() => {
if (getThreadBindingsState().persistQueueByAccountId.get(params.accountId) === next) {
getThreadBindingsState().persistQueueByAccountId.delete(params.accountId);
if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) {
PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId);
}
});
return next;
@ -415,7 +412,7 @@ export function createTelegramThreadBindingManager(
} = {},
): TelegramThreadBindingManager {
const accountId = normalizeAccountId(params.accountId);
const existing = getThreadBindingsState().managersByAccountId.get(accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing) {
return existing;
}
@ -433,7 +430,7 @@ export function createTelegramThreadBindingManager(
accountId,
conversationId: entry.conversationId,
});
getThreadBindingsState().bindingsByAccountConversation.set(key, {
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, {
...entry,
accountId,
});
@ -451,7 +448,7 @@ export function createTelegramThreadBindingManager(
if (!conversationId) {
return undefined;
}
return getThreadBindingsState().bindingsByAccountConversation.get(
return BINDINGS_BY_ACCOUNT_CONVERSATION.get(
resolveBindingKey({
accountId,
conversationId,
@ -474,7 +471,7 @@ export function createTelegramThreadBindingManager(
return null;
}
const key = resolveBindingKey({ accountId, conversationId });
const existing = getThreadBindingsState().bindingsByAccountConversation.get(key);
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
if (!existing) {
return null;
}
@ -482,7 +479,7 @@ export function createTelegramThreadBindingManager(
...existing,
lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
};
getThreadBindingsState().bindingsByAccountConversation.set(key, nextRecord);
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord);
persistBindingsSafely({
accountId,
persist: manager.shouldPersistMutations(),
@ -497,11 +494,11 @@ export function createTelegramThreadBindingManager(
return null;
}
const key = resolveBindingKey({ accountId, conversationId });
const removed = getThreadBindingsState().bindingsByAccountConversation.get(key) ?? null;
const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null;
if (!removed) {
return null;
}
getThreadBindingsState().bindingsByAccountConversation.delete(key);
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
persistBindingsSafely({
accountId,
persist: manager.shouldPersistMutations(),
@ -524,7 +521,7 @@ export function createTelegramThreadBindingManager(
accountId,
conversationId: entry.conversationId,
});
getThreadBindingsState().bindingsByAccountConversation.delete(key);
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
removed.push(entry);
}
if (removed.length > 0) {
@ -543,9 +540,9 @@ export function createTelegramThreadBindingManager(
sweepTimer = null;
}
unregisterSessionBindingAdapter({ channel: "telegram", accountId });
const existingManager = getThreadBindingsState().managersByAccountId.get(accountId);
const existingManager = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existingManager === manager) {
getThreadBindingsState().managersByAccountId.delete(accountId);
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
}
},
};
@ -577,7 +574,7 @@ export function createTelegramThreadBindingManager(
metadata: input.metadata,
},
});
getThreadBindingsState().bindingsByAccountConversation.set(
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
resolveBindingKey({ accountId, conversationId }),
record,
);
@ -717,14 +714,14 @@ export function createTelegramThreadBindingManager(
sweepTimer.unref?.();
}
getThreadBindingsState().managersByAccountId.set(accountId, manager);
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
return manager;
}
export function getTelegramThreadBindingManager(
accountId?: string,
): TelegramThreadBindingManager | null {
return getThreadBindingsState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
}
function updateTelegramBindingsBySessionKey(params: {
@ -744,7 +741,7 @@ function updateTelegramBindingsBySessionKey(params: {
conversationId: entry.conversationId,
});
const next = params.update(entry, now);
getThreadBindingsState().bindingsByAccountConversation.set(key, next);
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, next);
updated.push(next);
}
if (updated.length > 0) {
@ -802,12 +799,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: {
export const __testing = {
async resetTelegramThreadBindingsForTests() {
for (const manager of getThreadBindingsState().managersByAccountId.values()) {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values());
getThreadBindingsState().persistQueueByAccountId.clear();
getThreadBindingsState().managersByAccountId.clear();
getThreadBindingsState().bindingsByAccountConversation.clear();
await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values());
PERSIST_QUEUE_BY_ACCOUNT_ID.clear();
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
},
};

View File

@ -1,6 +1,5 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { vi } from "vitest";
import type { MockBaileysSocket } from "../../../test/mocks/baileys.js";
import { createMockBaileys } from "../../../test/mocks/baileys.js";
@ -33,21 +32,6 @@ export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
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) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
const mockModule = Object.create(null) as Record<string, unknown>;
@ -108,10 +92,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
configurable: true,
enumerable: true,
writable: true,
value:
typeof actual.resolveStorePath === "function"
? actual.resolveStorePath
: resolveStorePathFallback,
value: actual.resolveStorePath,
},
});
return mockModule;

View File

@ -0,0 +1,116 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_IMAGE_BYTES } from "../media/constants.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
const mocks = vi.hoisted(() => ({
loadImageFromRef: vi.fn(),
sanitizeImageBlocks: vi.fn(),
}));
vi.mock("./pi-embedded-runner/run/images.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./pi-embedded-runner/run/images.js")>();
return {
...actual,
loadImageFromRef: (...args: unknown[]) => mocks.loadImageFromRef(...args),
};
});
vi.mock("./tool-images.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./tool-images.js")>();
return {
...actual,
sanitizeImageBlocks: (...args: unknown[]) => mocks.sanitizeImageBlocks(...args),
};
});
import { loadPromptRefImages } from "./cli-runner/helpers.js";
describe("loadPromptRefImages", () => {
beforeEach(() => {
mocks.loadImageFromRef.mockReset();
mocks.sanitizeImageBlocks.mockReset();
mocks.sanitizeImageBlocks.mockImplementation(async (images: ImageContent[]) => ({
images,
dropped: 0,
}));
});
it("returns empty results when the prompt has no image refs", async () => {
await expect(
loadPromptRefImages({
prompt: "just text",
workspaceDir: "/workspace",
}),
).resolves.toEqual([]);
expect(mocks.loadImageFromRef).not.toHaveBeenCalled();
expect(mocks.sanitizeImageBlocks).not.toHaveBeenCalled();
});
it("passes the max-byte guardrail through load and sanitize", async () => {
const loadedImage: ImageContent = {
type: "image",
data: "c29tZS1pbWFnZQ==",
mimeType: "image/png",
};
const sanitizedImage: ImageContent = {
type: "image",
data: "c2FuaXRpemVkLWltYWdl",
mimeType: "image/jpeg",
};
const sandbox = {
root: "/sandbox",
bridge: {} as SandboxFsBridge,
};
mocks.loadImageFromRef.mockResolvedValueOnce(loadedImage);
mocks.sanitizeImageBlocks.mockResolvedValueOnce({ images: [sanitizedImage], dropped: 0 });
const result = await loadPromptRefImages({
prompt: "Look at /tmp/photo.png",
workspaceDir: "/workspace",
workspaceOnly: true,
sandbox,
});
const [ref, workspaceDir, options] = mocks.loadImageFromRef.mock.calls[0] ?? [];
expect(ref).toMatchObject({ resolved: "/tmp/photo.png", type: "path" });
expect(workspaceDir).toBe("/workspace");
expect(options).toEqual({
maxBytes: MAX_IMAGE_BYTES,
workspaceOnly: true,
sandbox,
});
expect(mocks.sanitizeImageBlocks).toHaveBeenCalledWith([loadedImage], "prompt:images", {
maxBytes: MAX_IMAGE_BYTES,
});
expect(result).toEqual([sanitizedImage]);
});
it("dedupes repeated refs and skips failed loads before sanitizing", async () => {
const loadedImage: ImageContent = {
type: "image",
data: "b25lLWltYWdl",
mimeType: "image/png",
};
mocks.loadImageFromRef.mockResolvedValueOnce(loadedImage).mockResolvedValueOnce(null);
const result = await loadPromptRefImages({
prompt: "Compare /tmp/a.png with /tmp/a.png and /tmp/b.png",
workspaceDir: "/workspace",
});
expect(mocks.loadImageFromRef).toHaveBeenCalledTimes(2);
expect(
mocks.loadImageFromRef.mock.calls.map(
(call: unknown[]) => (call[0] as { resolved?: string } | undefined)?.resolved,
),
).toEqual(["/tmp/a.png", "/tmp/b.png"]);
expect(mocks.sanitizeImageBlocks).toHaveBeenCalledWith([loadedImage], "prompt:images", {
maxBytes: MAX_IMAGE_BYTES,
});
expect(result).toEqual([loadedImage]);
});
});

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { runCliAgent } from "./cli-runner.js";
import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
@ -11,6 +12,8 @@ import type { WorkspaceBootstrapFile } from "./workspace.js";
const supervisorSpawnMock = vi.fn();
const enqueueSystemEventMock = vi.fn();
const requestHeartbeatNowMock = vi.fn();
const SMALL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const hoisted = vi.hoisted(() => {
type BootstrapContext = {
bootstrapFiles: WorkspaceBootstrapFile[];
@ -187,6 +190,135 @@ describe("runCliAgent with process supervisor", () => {
expect(promptCarrier).toContain("hi");
});
it("hydrates prompt media refs into CLI image args", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-prompt-image-"),
);
const sourceImage = path.join(tempDir, "bb-image.png");
await fs.writeFile(sourceImage, Buffer.from(SMALL_PNG_BASE64, "base64"));
try {
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: tempDir,
prompt: `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`,
provider: "codex-cli",
model: "gpt-5.2-codex",
timeoutMs: 1_000,
runId: "run-prompt-image",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] };
const argv = input.argv ?? [];
const imageArgIndex = argv.indexOf("--image");
expect(imageArgIndex).toBeGreaterThanOrEqual(0);
expect(argv[imageArgIndex + 1]).toContain("openclaw-cli-images-");
expect(argv[imageArgIndex + 1]).not.toBe(sourceImage);
});
it("appends hydrated prompt media refs to generic backend prompts", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-prompt-image-generic-"),
);
const sourceImage = path.join(tempDir, "claude-image.png");
await fs.writeFile(sourceImage, Buffer.from(SMALL_PNG_BASE64, "base64"));
try {
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: tempDir,
prompt: `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`,
provider: "claude-cli",
model: "claude-opus-4-1",
timeoutMs: 1_000,
runId: "run-prompt-image-generic",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string };
const argv = input.argv ?? [];
expect(argv).not.toContain("--image");
const promptCarrier = [input.input ?? "", ...argv].join("\n");
const appendedPath = argv.find((value) => value.includes("openclaw-cli-images-"));
expect(appendedPath).toBeDefined();
expect(appendedPath).not.toBe(sourceImage);
expect(promptCarrier).toContain(appendedPath ?? "");
});
it("prefers explicit images over prompt refs", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-explicit-images-"),
);
const sourceImage = path.join(tempDir, "ignored-prompt-image.png");
await fs.writeFile(sourceImage, Buffer.from(SMALL_PNG_BASE64, "base64"));
try {
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: tempDir,
prompt: `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`,
images: [{ type: "image", data: SMALL_PNG_BASE64, mimeType: "image/png" }],
provider: "codex-cli",
model: "gpt-5.2-codex",
timeoutMs: 1_000,
runId: "run-explicit-image-precedence",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] };
const argv = input.argv ?? [];
expect(argv.filter((arg) => arg === "--image")).toHaveLength(1);
});
it("fails with timeout when no-output watchdog trips", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({

View File

@ -26,6 +26,7 @@ import {
buildCliArgs,
buildSystemPrompt,
enqueueCliRun,
loadPromptRefImages,
normalizeCliModel,
parseCliJson,
parseCliJsonl,
@ -221,8 +222,12 @@ export async function runCliAgent(params: {
let prompt = prependBootstrapPromptWarning(params.prompt, bootstrapPromptWarning.lines, {
preserveExactPrompt: heartbeatPrompt,
});
if (params.images && params.images.length > 0) {
const imagePayload = await writeCliImages(params.images);
const resolvedImages =
params.images && params.images.length > 0
? params.images
: await loadPromptRefImages({ prompt, workspaceDir });
if (resolvedImages.length > 0) {
const imagePayload = await writeCliImages(resolvedImages);
imagePaths = imagePayload.paths;
cleanupImages = imagePayload.cleanup;
if (!backend.imageArg) {

View File

@ -8,15 +8,19 @@ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js";
import { MAX_IMAGE_BYTES } from "../../media/constants.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
import { isRecord } from "../../utils.js";
import { buildModelAliasLines } from "../model-alias-lines.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { resolveOwnerDisplaySetting } from "../owner-display.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { detectImageReferences, loadImageFromRef } from "../pi-embedded-runner/run/images.js";
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
import { detectRuntimeShell } from "../shell-utils.js";
import { buildSystemPromptParams } from "../system-prompt-params.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
import { sanitizeImageBlocks } from "../tool-images.js";
export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js";
const CLI_RUN_QUEUE = new KeyedAsyncQueue();
@ -324,6 +328,43 @@ export function appendImagePathsToPrompt(prompt: string, paths: string[]): strin
return `${trimmed}${separator}${paths.join("\n")}`;
}
export async function loadPromptRefImages(params: {
prompt: string;
workspaceDir: string;
maxBytes?: number;
workspaceOnly?: boolean;
sandbox?: { root: string; bridge: SandboxFsBridge };
}): Promise<ImageContent[]> {
const refs = detectImageReferences(params.prompt);
if (refs.length === 0) {
return [];
}
const maxBytes = params.maxBytes ?? MAX_IMAGE_BYTES;
const seen = new Set<string>();
const images: ImageContent[] = [];
for (const ref of refs) {
const key = `${ref.type}:${ref.resolved}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
const image = await loadImageFromRef(ref, params.workspaceDir, {
maxBytes,
workspaceOnly: params.workspaceOnly,
sandbox: params.sandbox,
});
if (image) {
images.push(image);
}
}
const { images: sanitizedImages } = await sanitizeImageBlocks(images, "prompt:images", {
maxBytes,
});
return sanitizedImages;
}
export async function writeCliImages(
images: ImageContent[],
): Promise<{ paths: string[]; cleanup: () => Promise<void> }> {

View File

@ -125,27 +125,6 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError("request ended without sending any chunks");
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", () => {

View File

@ -88,14 +88,6 @@ 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([
{
input: "Hello there!\n\nHello there!",

View File

@ -65,57 +65,6 @@ function formatRateLimitOrOverloadedErrorCopy(raw: string): string | 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 {
if (!raw) {
return false;
@ -617,11 +566,6 @@ export function formatAssistantErrorText(
return transientCopy;
}
const transportCopy = formatTransportErrorCopy(raw);
if (transportCopy) {
return transportCopy;
}
if (isTimeoutErrorMessage(raw)) {
return "LLM request timed out.";
}
@ -682,10 +626,6 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
if (prefixedCopy) {
return prefixedCopy;
}
const transportCopy = formatTransportErrorCopy(trimmed);
if (transportCopy) {
return transportCopy;
}
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}

View File

@ -58,16 +58,14 @@ describe("handleAgentEnd", () => {
expect(warn.mock.calls[0]?.[1]).toMatchObject({
event: "embedded_run_agent_end",
runId: "run-1",
error: "LLM request failed: connection refused by the provider endpoint.",
error: "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({
stream: "lifecycle",
data: {
phase: "error",
error: "LLM request failed: connection refused by the provider endpoint.",
error: "connection refused",
},
});
});
@ -94,7 +92,7 @@ describe("handleAgentEnd", () => {
failoverReason: "overloaded",
providerErrorType: "overloaded_error",
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. rawError={"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
"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.",
});
});
@ -114,7 +112,7 @@ describe("handleAgentEnd", () => {
const meta = warn.mock.calls[0]?.[1];
expect(meta).toMatchObject({
consoleMessage:
"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",
"embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=connection refused",
});
expect(meta?.consoleMessage).not.toContain("\n");
expect(meta?.consoleMessage).not.toContain("\r");

View File

@ -50,8 +50,6 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-";
const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown";
const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown";
const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview);
const rawErrorConsoleSuffix = safeRawErrorPreview ? ` rawError=${safeRawErrorPreview}` : "";
ctx.log.warn("embedded run agent end", {
event: "embedded_run_agent_end",
tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"],
@ -62,7 +60,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
model: lastAssistant.model,
provider: lastAssistant.provider,
...observedError,
consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}${rawErrorConsoleSuffix}`,
consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}`,
});
emitAgentEvent({
runId: ctx.params.runId,

View File

@ -1,5 +1,8 @@
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> }>>(
async (_sessionKey: string) => ({ messages: [] }),
);
@ -14,6 +17,10 @@ vi.mock("../gateway/call.js", () => ({
}),
}));
vi.mock("./tools/agent-step.js", () => ({
readLatestAssistantReply: readLatestAssistantReplyMock,
}));
describe("captureSubagentCompletionReply", () => {
let previousFastTestEnv: string | undefined;
let captureSubagentCompletionReply: (typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"];
@ -33,27 +40,23 @@ describe("captureSubagentCompletionReply", () => {
});
beforeEach(() => {
readLatestAssistantReplyMock.mockReset().mockResolvedValue(undefined);
chatHistoryMock.mockReset().mockResolvedValue({ messages: [] });
});
it("returns immediate assistant output from history without polling", async () => {
chatHistoryMock.mockResolvedValueOnce({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "Immediate assistant completion" }],
},
],
});
it("returns immediate assistant output without polling", async () => {
readLatestAssistantReplyMock.mockResolvedValueOnce("Immediate assistant completion");
const result = await captureSubagentCompletionReply("agent:main:subagent:child");
expect(result).toBe("Immediate assistant completion");
expect(chatHistoryMock).toHaveBeenCalledTimes(1);
expect(readLatestAssistantReplyMock).toHaveBeenCalledTimes(1);
expect(chatHistoryMock).not.toHaveBeenCalled();
});
it("polls briefly and returns late tool output once available", async () => {
vi.useFakeTimers();
readLatestAssistantReplyMock.mockResolvedValue(undefined);
chatHistoryMock.mockResolvedValueOnce({ messages: [] }).mockResolvedValueOnce({
messages: [
{
@ -79,6 +82,7 @@ describe("captureSubagentCompletionReply", () => {
it("returns undefined when no completion output arrives before retry window closes", async () => {
vi.useFakeTimers();
readLatestAssistantReplyMock.mockResolvedValue(undefined);
chatHistoryMock.mockResolvedValue({ messages: [] });
const pending = captureSubagentCompletionReply("agent:main:subagent:child");
@ -89,26 +93,4 @@ describe("captureSubagentCompletionReply", () => {
expect(chatHistoryMock).toHaveBeenCalled();
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.");
});
});

View File

@ -29,14 +29,10 @@ let fallbackRequesterResolution: {
requesterSessionKey: string;
requesterOrigin?: { channel?: string; to?: string; accountId?: string };
} | null = null;
let chatHistoryMessages: Array<Record<string, unknown>> = [];
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (request: GatewayCall) => {
gatewayCalls.push(request);
if (request.method === "chat.history") {
return { messages: chatHistoryMessages };
}
return await callGatewayImpl(request);
}),
}));
@ -142,7 +138,6 @@ function setupParentSessionFallback(parentSessionKey: string): void {
describe("subagent announce timeout config", () => {
beforeEach(() => {
gatewayCalls.length = 0;
chatHistoryMessages = [];
callGatewayImpl = async (request) => {
if (request.method === "chat.history") {
return { messages: [] };
@ -275,6 +270,7 @@ describe("subagent announce timeout config", () => {
it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => {
const parentSessionKey = "agent:main:subagent:parent";
setupParentSessionFallback(parentSessionKey);
// No sessionId on purpose: existence in store should still count as alive.
sessionStore[parentSessionKey] = { updatedAt: Date.now() };
await runAnnounceFlowForTest("run-parent-route", {
@ -305,147 +301,4 @@ describe("subagent announce timeout config", () => {
expect(directAgentCall?.params?.to).toBe("chan-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();
});
});

View File

@ -47,6 +47,7 @@ import {
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import type { SpawnSubagentMode } from "./subagent-spawn.js";
import { readLatestAssistantReply } from "./tools/agent-step.js";
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
@ -54,6 +55,7 @@ const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1";
const FAST_TEST_RETRY_INTERVAL_MS = 8;
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 90_000;
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
const GATEWAY_TIMEOUT_PATTERN = /gateway timeout/i;
let subagentRegistryRuntimePromise: Promise<
typeof import("./subagent-registry-runtime.js")
> | null = null;
@ -72,14 +74,6 @@ type ToolResultMessage = {
content?: unknown;
};
type SubagentOutputSnapshot = {
latestAssistantText?: string;
latestSilentText?: string;
latestRawText?: string;
assistantFragments: string[];
toolCallCount: number;
};
function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType<typeof loadConfig>): number {
const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
@ -116,7 +110,7 @@ const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
/no active .* listener/i,
/gateway not connected/i,
/gateway closed \(1006/i,
/gateway timeout/i,
GATEWAY_TIMEOUT_PATTERN,
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
];
@ -142,6 +136,11 @@ function isTransientAnnounceDeliveryError(error: unknown): boolean {
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> {
if (ms <= 0) {
return;
@ -169,6 +168,7 @@ async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Prom
async function runAnnounceDeliveryWithRetry<T>(params: {
operation: string;
noRetryOnGatewayTimeout?: boolean;
signal?: AbortSignal;
run: () => Promise<T>;
}): Promise<T> {
@ -180,6 +180,9 @@ async function runAnnounceDeliveryWithRetry<T>(params: {
try {
return await params.run();
} catch (err) {
if (params.noRetryOnGatewayTimeout && isGatewayTimeoutError(err)) {
throw err;
}
const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex];
if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) {
throw err;
@ -284,126 +287,42 @@ function extractSubagentOutputText(message: unknown): string {
return "";
}
function countAssistantToolCalls(content: unknown): number {
if (!Array.isArray(content)) {
return 0;
}
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;
async function readLatestSubagentOutput(sessionKey: string): Promise<string | undefined> {
try {
const latestAssistant = await readLatestAssistantReply({
sessionKey,
limit: 50,
});
if (latestAssistant?.trim()) {
return latestAssistant;
}
} catch {
// Best-effort: fall back to richer history parsing below.
}
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> }>({
method: "chat.history",
params: { sessionKey, limit: 100 },
params: { sessionKey, limit: 50 },
});
const messages = Array.isArray(history?.messages) ? history.messages : [];
return selectSubagentOutputText(summarizeSubagentOutputHistory(messages), outcome);
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
const text = extractSubagentOutputText(msg);
if (text) {
return text;
}
}
return undefined;
}
async function readLatestSubagentOutputWithRetry(params: {
sessionKey: string;
maxWaitMs: number;
outcome?: SubagentRunOutcome;
}): Promise<string | undefined> {
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));
let result: string | undefined;
while (Date.now() < deadline) {
result = await readSubagentOutput(params.sessionKey, params.outcome);
result = await readLatestSubagentOutput(params.sessionKey);
if (result?.trim()) {
return result;
}
@ -415,7 +334,7 @@ async function readLatestSubagentOutputWithRetry(params: {
export async function captureSubagentCompletionReply(
sessionKey: string,
): Promise<string | undefined> {
const immediate = await readSubagentOutput(sessionKey);
const immediate = await readLatestSubagentOutput(sessionKey);
if (immediate?.trim()) {
return immediate;
}
@ -892,6 +811,7 @@ async function sendSubagentAnnounceDirectly(params: {
operation: params.expectsCompletionMessage
? "completion direct announce agent call"
: "direct announce agent call",
noRetryOnGatewayTimeout: params.expectsCompletionMessage && shouldDeliverExternally,
signal: params.signal,
run: async () =>
await callGateway({
@ -1401,14 +1321,13 @@ export async function runSubagentAnnounceFlow(params: {
(isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN));
if (!reply) {
reply = await readSubagentOutput(params.childSessionKey, outcome);
reply = await readLatestSubagentOutput(params.childSessionKey);
}
if (!reply?.trim()) {
reply = await readLatestSubagentOutputWithRetry({
sessionKey: params.childSessionKey,
maxWaitMs: params.timeoutMs,
outcome,
});
}

View File

@ -43,10 +43,7 @@ export const handlePluginCommand: CommandHandler = async (
to: command.to,
accountId: params.ctx.AccountId ?? undefined,
messageThreadId:
typeof params.ctx.MessageThreadId === "string" ||
typeof params.ctx.MessageThreadId === "number"
? params.ctx.MessageThreadId
: undefined,
typeof params.ctx.MessageThreadId === "number" ? params.ctx.MessageThreadId : undefined,
});
return {

View File

@ -3,7 +3,6 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.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 { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
@ -517,11 +516,8 @@ describe("doctor config flow", () => {
});
it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => {
const globalFetch = vi.fn(async () => {
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 fetchSpy = vi.fn(async (url: string) => {
const u = String(url);
const chatId = new URL(u).searchParams.get("chat_id") ?? "";
const id =
chatId.toLowerCase() === "@testuser"
@ -538,14 +534,7 @@ describe("doctor config flow", () => {
json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }),
} as unknown as Response;
});
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);
vi.stubGlobal("fetch", fetchSpy);
try {
const result = await runDoctorConfigWithInput({
repair: true,
@ -591,8 +580,6 @@ describe("doctor config flow", () => {
expect(cfg.channels.telegram.accounts.default.allowFrom).toEqual(["111"]);
expect(cfg.channels.telegram.accounts.default.groupAllowFrom).toEqual(["222"]);
} finally {
makeProxyFetch.mockRestore();
resolveTelegramFetch.mockRestore();
vi.unstubAllGlobals();
}
});
@ -645,88 +632,6 @@ 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 () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
const fetchSpy = vi.fn();

View File

@ -1,8 +1,8 @@
import {
fetchTelegramChatId,
inspectTelegramAccount,
isNumericTelegramUserId,
listTelegramAccountIds,
lookupTelegramChatId,
normalizeTelegramAllowFromEntry,
} from "../../extensions/telegram/api.js";
import { normalizeChatChannelId } from "../channels/registry.js";
@ -15,7 +15,6 @@ import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
import { formatConfigIssueLines } from "../config/issue-format.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 { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
import {
@ -85,13 +84,6 @@ type TelegramAllowFromListRef = {
key: "allowFrom" | "groupAllowFrom";
};
type ResolvedTelegramLookupAccount = {
token: string;
apiRoot?: string;
proxyUrl?: string;
network?: TelegramNetworkConfig;
};
function asObjectRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@ -407,34 +399,29 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
});
const tokenResolutionWarnings: string[] = [];
const lookupAccounts: ResolvedTelegramLookupAccount[] = [];
const seenLookupAccounts = new Set<string>();
for (const accountId of listTelegramAccountIds(resolvedConfig)) {
let account: NonNullable<ReturnType<typeof resolveTelegramAccount>>;
try {
account = resolveTelegramAccount({ cfg: resolvedConfig, accountId });
} catch (error) {
tokenResolutionWarnings.push(
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
);
continue;
}
const token = account.tokenSource === "none" ? "" : account.token.trim();
if (!token) {
continue;
}
const apiRoot = account.config.apiRoot?.trim() || undefined;
const proxyUrl = account.config.proxy?.trim() || undefined;
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 });
}
const tokens = Array.from(
new Set(
listTelegramAccountIds(resolvedConfig)
.map((accountId) => {
try {
return resolveTelegramAccount({ cfg: resolvedConfig, accountId });
} catch (error) {
tokenResolutionWarnings.push(
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
);
return null;
}
})
.filter((account): account is NonNullable<ReturnType<typeof resolveTelegramAccount>> =>
Boolean(account),
)
.map((account) => (account.tokenSource === "none" ? "" : account.token))
.map((token) => token.trim())
.filter(Boolean),
),
);
if (lookupAccounts.length === 0) {
if (tokens.length === 0) {
return {
config: cfg,
changes: [
@ -462,17 +449,14 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
return null;
}
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
for (const account of lookupAccounts) {
for (const token of tokens) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const id = await lookupTelegramChatId({
token: account.token,
const id = await fetchTelegramChatId({
token,
chatId: username,
signal: controller.signal,
apiRoot: account.apiRoot,
proxyUrl: account.proxyUrl,
network: account.network,
});
if (id) {
return id;

View File

@ -567,47 +567,6 @@ describe("gateway-status command", () => {
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 () => {
const { runtime } = createRuntimeCapture();
await withEnvAsync({ USER: "steipete" }, async () => {

View File

@ -176,7 +176,7 @@ export async function gatewayStatusCommand(
token: authResolution.token,
password: authResolution.password,
};
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target);
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
const probe = await probeGateway({
url: target.url,
auth,

View File

@ -6,7 +6,6 @@ import {
isScopeLimitedProbeFailure,
renderProbeSummaryLine,
resolveAuthForTarget,
resolveProbeBudgetMs,
} from "./helpers.js";
describe("extractConfigSummary", () => {
@ -274,21 +273,3 @@ describe("probe reachability classification", () => {
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);
});
});

View File

@ -116,21 +116,14 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew
return targets;
}
export function resolveProbeBudgetMs(
overallMs: number,
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);
export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
if (kind === "localLoopback") {
return Math.min(800, overallMs);
}
if (kind === "sshTunnel") {
return Math.min(2000, overallMs);
}
return Math.min(1500, overallMs);
}
export function sanitizeSshTarget(value: unknown): string | null {

View File

@ -1532,8 +1532,6 @@ export const FIELD_HELP: Record<string, string> = {
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
"channels.telegram.silentErrorReplies":
"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":
"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.",
"channels.telegram.threadBindings.idleHours":

View File

@ -732,7 +732,6 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.silentErrorReplies": "Telegram Silent Error Replies",
"channels.telegram.apiRoot": "Telegram API Root URL",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.telegram.execApprovals": "Telegram Exec Approvals",
"channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled",

View File

@ -216,8 +216,6 @@ export type TelegramAccountConfig = {
* Telegram expects unicode emoji (e.g., "👀") rather than shortcodes.
*/
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 = {

View File

@ -280,7 +280,6 @@ export const TelegramAccountSchemaBase = z
silentErrorReplies: z.boolean().optional(),
responsePrefix: z.string().optional(),
ackReaction: z.string().optional(),
apiRoot: z.string().url().optional(),
})
.strict();

View File

@ -40,15 +40,9 @@ vi.mock("./client.js", () => ({
GatewayClient: MockGatewayClient,
}));
const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js");
const { probeGateway } = await import("./probe.js");
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 () => {
const result = await probeGateway({
url: "ws://127.0.0.1:18789",

View File

@ -29,13 +29,6 @@ export type GatewayProbeResult = {
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: {
url: string;
auth?: GatewayProbeAuth;
@ -151,18 +144,21 @@ export async function probeGateway(opts: {
},
});
const timer = setTimeout(() => {
settle({
ok: false,
connectLatencyMs,
error: connectError ? `connect failed: ${connectError}` : "timeout",
close,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
}, clampProbeTimeoutMs(opts.timeoutMs));
const timer = setTimeout(
() => {
settle({
ok: false,
connectLatencyMs,
error: connectError ? `connect failed: ${connectError}` : "timeout",
close,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
},
Math.max(250, opts.timeoutMs),
);
client.start();
});

View File

@ -3,10 +3,8 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
clearDeviceBootstrapTokens,
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
issueDeviceBootstrapToken,
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "./device-bootstrap.js";
@ -17,22 +15,6 @@ function resolveBootstrapPath(baseDir: string): string {
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 () => {
vi.useRealTimers();
await tempDirs.cleanup();
@ -65,85 +47,43 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
});
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 () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyBootstrapToken(baseDir, issued.token, {
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: " ",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
@ -155,9 +95,16 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({
ok: true,
});
await expect(
verifyDeviceBootstrapToken({
token: ` ${issued.token} `,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
});
@ -166,10 +113,16 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
await issueDeviceBootstrapToken({ baseDir });
await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(
verifyDeviceBootstrapToken({
token: " ",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(
verifyDeviceBootstrapToken({
@ -226,11 +179,26 @@ describe("device bootstrap tokens", () => {
"utf8",
);
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: "legacyToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(
verifyDeviceBootstrapToken({
token: "expiredToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
});

View File

@ -79,41 +79,6 @@ export async function issueDeviceBootstrapToken(
});
}
export async function clearDeviceBootstrapTokens(
params: {
baseDir?: string;
} = {},
): Promise<{ removed: number }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const removed = Object.keys(state).length;
await persistState({}, params.baseDir);
return { removed };
});
}
export async function revokeDeviceBootstrapToken(params: {
token: string;
baseDir?: string;
}): Promise<{ removed: boolean }> {
return await withLock(async () => {
const providedToken = params.token.trim();
if (!providedToken) {
return { removed: false };
}
const state = await loadState(params.baseDir);
const found = Object.entries(state).find(([, candidate]) =>
verifyPairingToken(providedToken, candidate.token),
);
if (!found) {
return { removed: false };
}
delete state[found[0]];
await persistState(state, params.baseDir);
return { removed: true };
});
}
export async function verifyDeviceBootstrapToken(params: {
token: string;
deviceId: string;
@ -128,13 +93,12 @@ export async function verifyDeviceBootstrapToken(params: {
if (!providedToken) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const found = Object.entries(state).find(([, candidate]) =>
const entry = Object.values(state).find((candidate) =>
verifyPairingToken(providedToken, candidate.token),
);
if (!found) {
if (!entry) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const [tokenKey] = found;
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
@ -145,7 +109,7 @@ export async function verifyDeviceBootstrapToken(params: {
// Bootstrap setup codes are single-use. Consume the record before returning
// success so the same token cannot be replayed to mutate a pending request.
delete state[tokenKey];
delete state[entry.token];
await persistState(state, params.baseDir);
return { ok: true };
});

View File

@ -1,8 +1,4 @@
// Shared bootstrap/pairing helpers for plugins that provision remote devices.
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
export {
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken,
revokeDeviceBootstrapToken,
} from "../infra/device-bootstrap.js";
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";

View File

@ -322,7 +322,7 @@ function resolveBindingConversationFromCommand(params: {
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
messageThreadId?: number;
}): {
channel: string;
accountId: string;

View File

@ -82,7 +82,7 @@ export {
isNumericTelegramUserId,
normalizeTelegramAllowFromEntry,
} from "../../../extensions/telegram/api.js";
export { fetchTelegramChatId, lookupTelegramChatId } from "../../../extensions/telegram/api.js";
export { fetchTelegramChatId } from "../../../extensions/telegram/api.js";
export {
resolveTelegramInlineButtonsScope,
resolveTelegramTargetChatType,

View File

@ -963,7 +963,7 @@ export type PluginCommandContext = {
/** Account id for multi-account channels */
accountId?: string;
/** Thread/topic id if available */
messageThreadId?: string | number;
messageThreadId?: number;
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;

View File

@ -31,6 +31,14 @@
"resolvedPath": "extensions/imessage/runtime-api.js",
"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",
"line": 10,