From bfc9736366561aa8ba87ee43cd5a2b195195892a Mon Sep 17 00:00:00 2001
From: Mariano <132747814+mbelinky@users.noreply.github.com>
Date: Tue, 17 Feb 2026 20:08:50 +0000
Subject: [PATCH] feat: share to openclaw ios app (#19424)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 0a7ab8589ac23d0743d4377683d60601a8c19e61
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
---
.gitignore | 3 +
CHANGELOG.md | 1 +
apps/ios/LocalSigning.xcconfig.example | 12 +
apps/ios/ShareExtension/Info.plist | 43 ++
.../ShareExtension/ShareViewController.swift | 524 ++++++++++++++++++
apps/ios/Signing.xcconfig | 13 +
apps/ios/Sources/Info.plist | 2 -
apps/ios/Sources/Model/NodeAppModel.swift | 144 ++++-
.../Onboarding/OnboardingWizardView.swift | 70 ++-
apps/ios/Sources/RootCanvas.swift | 3 +
apps/ios/Sources/Settings/SettingsTab.swift | 192 ++++---
.../ios/Tests/ShareToAgentDeepLinkTests.swift | 51 ++
apps/ios/project.yml | 40 +-
.../ShareGatewayRelaySettings.swift | 62 +++
.../OpenClawKit/ShareToAgentDeepLink.swift | 62 +++
.../OpenClawKit/ShareToAgentSettings.swift | 29 +
.../isolated-agent/run.skill-filter.test.ts | 1 +
src/gateway/server-node-events.test.ts | 1 +
src/gateway/server-node-events.ts | 155 +++++-
19 files changed, 1300 insertions(+), 108 deletions(-)
create mode 100644 apps/ios/LocalSigning.xcconfig.example
create mode 100644 apps/ios/ShareExtension/Info.plist
create mode 100644 apps/ios/ShareExtension/ShareViewController.swift
create mode 100644 apps/ios/Signing.xcconfig
create mode 100644 apps/ios/Tests/ShareToAgentDeepLinkTests.swift
create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift
create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift
create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift
diff --git a/.gitignore b/.gitignore
index 162f2fb6ca..2ee577593b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -88,3 +88,6 @@ USER.md
!.agent/workflows/
/local/
package-lock.json
+
+# Local iOS signing overrides
+apps/ios/LocalSigning.xcconfig
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 195f42e1de..0cb71018bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan.
- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
+- iOS/Share: add an iOS share extension that forwards shared URL/text/image content directly to gateway `agent.request`, with delivery-route fallback and optional receipt acknowledgements. (#19424) Thanks @mbelinky.
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg.
- Mattermost: add emoji reaction actions plus reaction event notifications, including an explicit boolean `remove` flag to avoid accidental removals. (#18608) Thanks @echo931.
diff --git a/apps/ios/LocalSigning.xcconfig.example b/apps/ios/LocalSigning.xcconfig.example
new file mode 100644
index 0000000000..6549213108
--- /dev/null
+++ b/apps/ios/LocalSigning.xcconfig.example
@@ -0,0 +1,12 @@
+// Copy to LocalSigning.xcconfig for personal local signing overrides.
+// This file is only an example and should stay committed.
+
+OPENCLAW_CODE_SIGN_STYLE = Automatic
+OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL
+
+OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano
+OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share
+
+// Leave empty with automatic signing.
+OPENCLAW_APP_PROFILE =
+OPENCLAW_SHARE_PROFILE =
diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist
new file mode 100644
index 0000000000..3ffdfd4841
--- /dev/null
+++ b/apps/ios/ShareExtension/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleDisplayName
+ OpenClaw Share
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 2026.2.16
+ CFBundleVersion
+ 20260216
+ NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsImageWithMaxCount
+ 10
+ NSExtensionActivationSupportsText
+
+ NSExtensionActivationSupportsWebURLWithMaxCount
+ 1
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+
+
+
diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift
new file mode 100644
index 0000000000..0f720f16a9
--- /dev/null
+++ b/apps/ios/ShareExtension/ShareViewController.swift
@@ -0,0 +1,524 @@
+import Foundation
+import OpenClawKit
+import os
+import UIKit
+import UniformTypeIdentifiers
+
+final class ShareViewController: UIViewController {
+ private struct ShareAttachment: Codable {
+ var type: String
+ var mimeType: String
+ var fileName: String
+ var content: String
+ }
+
+ private struct ExtractedShareContent {
+ var payload: SharedContentPayload
+ var attachments: [ShareAttachment]
+ }
+
+ private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
+ private var statusLabel: UILabel?
+ private let draftTextView = UITextView()
+ private let sendButton = UIButton(type: .system)
+ private let cancelButton = UIButton(type: .system)
+ private var didPrepareDraft = false
+ private var isSending = false
+ private var pendingAttachments: [ShareAttachment] = []
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
+ self.setupUI()
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ guard !self.didPrepareDraft else { return }
+ self.didPrepareDraft = true
+ Task { await self.prepareDraft() }
+ }
+
+ private func setupUI() {
+ self.view.backgroundColor = .systemBackground
+
+ self.draftTextView.translatesAutoresizingMaskIntoConstraints = false
+ self.draftTextView.font = .preferredFont(forTextStyle: .body)
+ self.draftTextView.backgroundColor = UIColor.secondarySystemBackground
+ self.draftTextView.layer.cornerRadius = 10
+ self.draftTextView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10)
+
+ self.sendButton.translatesAutoresizingMaskIntoConstraints = false
+ self.sendButton.setTitle("Send to OpenClaw", for: .normal)
+ self.sendButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
+ self.sendButton.addTarget(self, action: #selector(self.handleSendTap), for: .touchUpInside)
+ self.sendButton.isEnabled = false
+
+ self.cancelButton.translatesAutoresizingMaskIntoConstraints = false
+ self.cancelButton.setTitle("Cancel", for: .normal)
+ self.cancelButton.addTarget(self, action: #selector(self.handleCancelTap), for: .touchUpInside)
+
+ let buttons = UIStackView(arrangedSubviews: [self.cancelButton, self.sendButton])
+ buttons.translatesAutoresizingMaskIntoConstraints = false
+ buttons.axis = .horizontal
+ buttons.alignment = .fill
+ buttons.distribution = .fillEqually
+ buttons.spacing = 12
+
+ self.view.addSubview(self.draftTextView)
+ self.view.addSubview(buttons)
+
+ NSLayoutConstraint.activate([
+ self.draftTextView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 14),
+ self.draftTextView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14),
+ self.draftTextView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14),
+ self.draftTextView.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -12),
+
+ buttons.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14),
+ buttons.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14),
+ buttons.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, constant: -8),
+ buttons.heightAnchor.constraint(equalToConstant: 44),
+ ])
+ }
+
+ private func prepareDraft() async {
+ let traceId = UUID().uuidString
+ ShareGatewayRelaySettings.saveLastEvent("Share opened.")
+ self.showStatus("Preparing share…")
+ self.logger.info("share begin trace=\(traceId, privacy: .public)")
+ let extracted = await self.extractSharedContent()
+ let payload = extracted.payload
+ self.pendingAttachments = extracted.attachments
+ self.logger.info(
+ "share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)"
+ )
+ let message = self.composeDraft(from: payload)
+ await MainActor.run {
+ self.draftTextView.text = message
+ self.sendButton.isEnabled = true
+ self.draftTextView.becomeFirstResponder()
+ }
+ if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.")
+ self.showStatus("Add a message, then tap Send.")
+ } else {
+ ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.")
+ self.showStatus("Edit text, then tap Send.")
+ }
+ }
+
+ @objc
+ private func handleSendTap() {
+ guard !self.isSending else { return }
+ Task { await self.sendCurrentDraft() }
+ }
+
+ @objc
+ private func handleCancelTap() {
+ self.extensionContext?.completeRequest(returningItems: nil)
+ }
+
+ private func sendCurrentDraft() async {
+ let message = await MainActor.run { self.draftTextView.text ?? "" }
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.")
+ self.showStatus("Message is empty.")
+ return
+ }
+
+ await MainActor.run {
+ self.isSending = true
+ self.sendButton.isEnabled = false
+ self.cancelButton.isEnabled = false
+ }
+ self.showStatus("Sending to OpenClaw gateway…")
+ ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…")
+ do {
+ try await self.sendMessageToGateway(trimmed, attachments: self.pendingAttachments)
+ ShareGatewayRelaySettings.saveLastEvent(
+ "Sent to gateway (\(trimmed.count) chars, \(self.pendingAttachments.count) attachment(s)).")
+ self.showStatus("Sent to OpenClaw.")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
+ self.extensionContext?.completeRequest(returningItems: nil)
+ }
+ } catch {
+ self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)")
+ ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)")
+ self.showStatus("Send failed: \(error.localizedDescription)")
+ await MainActor.run {
+ self.isSending = false
+ self.sendButton.isEnabled = true
+ self.cancelButton.isEnabled = true
+ }
+ }
+ }
+
+ private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws {
+ guard let config = ShareGatewayRelaySettings.loadConfig() else {
+ throw NSError(
+ domain: "OpenClawShare",
+ code: 10,
+ userInfo: [NSLocalizedDescriptionKey: "OpenClaw is not connected to a gateway yet."])
+ }
+ guard let url = URL(string: config.gatewayURLString) else {
+ throw NSError(
+ domain: "OpenClawShare",
+ code: 11,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid saved gateway URL."])
+ }
+
+ let gateway = GatewayNodeSession()
+ defer {
+ Task { await gateway.disconnect() }
+ }
+ let makeOptions: (String) -> GatewayConnectOptions = { clientId in
+ GatewayConnectOptions(
+ role: "node",
+ scopes: [],
+ caps: [],
+ commands: [],
+ permissions: [:],
+ clientId: clientId,
+ clientMode: "node",
+ clientDisplayName: "OpenClaw Share",
+ includeDeviceIdentity: false)
+ }
+
+ do {
+ try await gateway.connect(
+ url: url,
+ token: config.token,
+ password: config.password,
+ connectOptions: makeOptions("openclaw-ios"),
+ sessionBox: nil,
+ onConnected: {},
+ onDisconnected: { _ in },
+ onInvoke: { req in
+ BridgeInvokeResponse(
+ id: req.id,
+ ok: false,
+ error: OpenClawNodeError(
+ code: .invalidRequest,
+ message: "share extension does not support node invoke"))
+ })
+ } catch {
+ let text = error.localizedDescription.lowercased()
+ let expectsLegacyClientId = text.contains("invalid connect params") && text.contains("/client/id")
+ guard expectsLegacyClientId else { throw error }
+ try await gateway.connect(
+ url: url,
+ token: config.token,
+ password: config.password,
+ connectOptions: makeOptions("moltbot-ios"),
+ sessionBox: nil,
+ onConnected: {},
+ onDisconnected: { _ in },
+ onInvoke: { req in
+ BridgeInvokeResponse(
+ id: req.id,
+ ok: false,
+ error: OpenClawNodeError(
+ code: .invalidRequest,
+ message: "share extension does not support node invoke"))
+ })
+ }
+
+ struct AgentRequestPayload: Codable {
+ var message: String
+ var sessionKey: String?
+ var thinking: String
+ var deliver: Bool
+ var attachments: [ShareAttachment]?
+ var receipt: Bool
+ var receiptText: String?
+ var to: String?
+ var channel: String?
+ var timeoutSeconds: Int?
+ var key: String?
+ }
+
+ let params = AgentRequestPayload(
+ message: message,
+ sessionKey: config.sessionKey,
+ thinking: "low",
+ deliver: true,
+ attachments: attachments.isEmpty ? nil : attachments,
+ receipt: true,
+ receiptText: "Just received your iOS share + request, working on it.",
+ to: config.deliveryTo,
+ channel: config.deliveryChannel,
+ timeoutSeconds: nil,
+ key: UUID().uuidString)
+ let data = try JSONEncoder().encode(params)
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw NSError(
+ domain: "OpenClawShare",
+ code: 12,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload."])
+ }
+ struct NodeEventParams: Codable {
+ var event: String
+ var payloadJSON: String
+ }
+ let eventData = try JSONEncoder().encode(NodeEventParams(event: "agent.request", payloadJSON: json))
+ guard let nodeEventParams = String(data: eventData, encoding: .utf8) else {
+ throw NSError(
+ domain: "OpenClawShare",
+ code: 13,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to encode node event payload."])
+ }
+ _ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25)
+ }
+
+ private func showStatus(_ text: String) {
+ DispatchQueue.main.async {
+ let label: UILabel
+ if let existing = self.statusLabel {
+ label = existing
+ } else {
+ let newLabel = UILabel()
+ newLabel.translatesAutoresizingMaskIntoConstraints = false
+ newLabel.numberOfLines = 0
+ newLabel.textAlignment = .center
+ newLabel.font = .preferredFont(forTextStyle: .body)
+ newLabel.textColor = .label
+ newLabel.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.92)
+ newLabel.layer.cornerRadius = 12
+ newLabel.clipsToBounds = true
+ newLabel.layoutMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
+ self.view.addSubview(newLabel)
+ NSLayoutConstraint.activate([
+ newLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18),
+ newLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18),
+ newLabel.bottomAnchor.constraint(equalTo: self.sendButton.topAnchor, constant: -10),
+ ])
+ self.statusLabel = newLabel
+ label = newLabel
+ }
+ label.text = " \(text) "
+ }
+ }
+
+ private func composeDraft(from payload: SharedContentPayload) -> String {
+ var lines: [String] = []
+ let title = self.sanitizeDraftFragment(payload.title)
+ let text = self.sanitizeDraftFragment(payload.text)
+ let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+
+ if let title, !title.isEmpty { lines.append(title) }
+ if let text, !text.isEmpty { lines.append(text) }
+ if !url.isEmpty { lines.append(url) }
+
+ return lines.joined(separator: "\n\n")
+ }
+
+ private func sanitizeDraftFragment(_ raw: String?) -> String? {
+ guard let raw else { return nil }
+ let banned = [
+ "shared from ios.",
+ "text:",
+ "shared attachment(s):",
+ "please help me with this.",
+ "please help me with this.w",
+ ]
+ let cleanedLines = raw
+ .components(separatedBy: .newlines)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { line in
+ guard !line.isEmpty else { return false }
+ let lowered = line.lowercased()
+ return !banned.contains { lowered == $0 || lowered.hasPrefix($0) }
+ }
+ let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ return cleaned.isEmpty ? nil : cleaned
+ }
+
+ private func extractSharedContent() async -> ExtractedShareContent {
+ guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else {
+ return ExtractedShareContent(
+ payload: SharedContentPayload(title: nil, url: nil, text: nil),
+ attachments: [])
+ }
+
+ var title: String?
+ var sharedURL: URL?
+ var sharedText: String?
+ var imageCount = 0
+ var videoCount = 0
+ var fileCount = 0
+ var unknownCount = 0
+ var attachments: [ShareAttachment] = []
+ let maxImageAttachments = 3
+
+ for item in items {
+ if title == nil {
+ title = item.attributedTitle?.string ?? item.attributedContentText?.string
+ }
+
+ for provider in item.attachments ?? [] {
+ if sharedURL == nil {
+ sharedURL = await self.loadURL(from: provider)
+ }
+
+ if sharedText == nil {
+ sharedText = await self.loadText(from: provider)
+ }
+
+ if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
+ imageCount += 1
+ if attachments.count < maxImageAttachments,
+ let attachment = await self.loadImageAttachment(from: provider, index: attachments.count)
+ {
+ attachments.append(attachment)
+ }
+ } else if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
+ videoCount += 1
+ } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
+ fileCount += 1
+ } else {
+ unknownCount += 1
+ }
+
+ }
+ }
+
+ _ = imageCount
+ _ = videoCount
+ _ = fileCount
+ _ = unknownCount
+
+ return ExtractedShareContent(
+ payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText),
+ attachments: attachments)
+ }
+
+ private func loadImageAttachment(from provider: NSItemProvider, index: Int) async -> ShareAttachment? {
+ let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier
+ guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else {
+ return nil
+ }
+
+ let maxBytes = 5_000_000
+ guard let image = UIImage(data: rawData),
+ let data = self.normalizedJPEGData(from: image, maxBytes: maxBytes)
+ else {
+ return nil
+ }
+
+ return ShareAttachment(
+ type: "image",
+ mimeType: "image/jpeg",
+ fileName: "shared-image-\(index + 1).jpg",
+ content: data.base64EncodedString())
+ }
+
+ private func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String? {
+ for identifier in provider.registeredTypeIdentifiers {
+ guard let utType = UTType(identifier) else { continue }
+ if utType.conforms(to: .image) {
+ return identifier
+ }
+ }
+ return nil
+ }
+
+ private func normalizedJPEGData(from image: UIImage, maxBytes: Int) -> Data? {
+ var quality: CGFloat = 0.9
+ while quality >= 0.4 {
+ if let data = image.jpegData(compressionQuality: quality), data.count <= maxBytes {
+ return data
+ }
+ quality -= 0.1
+ }
+ guard let fallback = image.jpegData(compressionQuality: 0.35) else { return nil }
+ if fallback.count <= maxBytes { return fallback }
+ return nil
+ }
+
+ private func loadURL(from provider: NSItemProvider) async -> URL? {
+ if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
+ if let url = await self.loadURLValue(
+ from: provider,
+ typeIdentifier: UTType.url.identifier)
+ {
+ return url
+ }
+ }
+
+ if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
+ if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier),
+ let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
+ url.scheme != nil
+ {
+ return url
+ }
+ }
+
+ return nil
+ }
+
+ private func loadText(from provider: NSItemProvider) async -> String? {
+ if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
+ if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.plainText.identifier) {
+ return text
+ }
+ }
+
+ if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
+ if let url = await self.loadURLValue(from: provider, typeIdentifier: UTType.url.identifier) {
+ return url.absoluteString
+ }
+ }
+
+ return nil
+ }
+
+ private func loadURLValue(from provider: NSItemProvider, typeIdentifier: String) async -> URL? {
+ await withCheckedContinuation { continuation in
+ provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
+ if let url = item as? URL {
+ continuation.resume(returning: url)
+ return
+ }
+ if let str = item as? String, let url = URL(string: str) {
+ continuation.resume(returning: url)
+ return
+ }
+ if let ns = item as? NSString, let url = URL(string: ns as String) {
+ continuation.resume(returning: url)
+ return
+ }
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+
+ private func loadTextValue(from provider: NSItemProvider, typeIdentifier: String) async -> String? {
+ await withCheckedContinuation { continuation in
+ provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
+ if let text = item as? String {
+ continuation.resume(returning: text)
+ return
+ }
+ if let text = item as? NSString {
+ continuation.resume(returning: text as String)
+ return
+ }
+ if let text = item as? NSAttributedString {
+ continuation.resume(returning: text.string)
+ return
+ }
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+
+ private func loadDataValue(from provider: NSItemProvider, typeIdentifier: String) async -> Data? {
+ await withCheckedContinuation { continuation in
+ provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
+ continuation.resume(returning: data)
+ }
+ }
+ }
+}
diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig
new file mode 100644
index 0000000000..c11f974e9e
--- /dev/null
+++ b/apps/ios/Signing.xcconfig
@@ -0,0 +1,13 @@
+// Default signing values for shared/repo builds.
+// For local development overrides, create LocalSigning.xcconfig (git-ignored).
+
+OPENCLAW_CODE_SIGN_STYLE = Manual
+OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
+
+OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
+OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share
+
+OPENCLAW_APP_PROFILE = ai.openclaw.ios Development
+OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development
+
+#include? "LocalSigning.xcconfig"
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 3182e43d30..d56fc5b658 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -18,8 +18,6 @@
$(PRODUCT_NAME)
CFBundlePackageType
APPL
- CFBundleShortVersionString
- 2026.2.16
CFBundleVersion
20260216
NSAppTransportSecurity
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 5b59af1585..d1f136d3b4 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -2,6 +2,7 @@ import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Observation
+import os
import SwiftUI
import UIKit
import UserNotifications
@@ -39,6 +40,7 @@ private final class NotificationInvokeLatch: @unchecked Sendable {
@MainActor
@Observable
final class NodeAppModel {
+ private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
enum CameraHUDKind {
case photo
case recording
@@ -67,6 +69,8 @@ final class NodeAppModel {
var selectedAgentId: String?
var gatewayDefaultAgentId: String?
var gatewayAgents: [AgentSummary] = []
+ var lastShareEventText: String = "No share events yet."
+ var openChatRequestID: Int = 0
var mainSessionKey: String {
let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey)
@@ -120,6 +124,8 @@ final class NodeAppModel {
private var gatewayConnected = false
private var operatorConnected = false
+ private var shareDeliveryChannel: String?
+ private var shareDeliveryTo: String?
var gatewaySession: GatewayNodeSession { self.nodeGateway }
var operatorSession: GatewayNodeSession { self.operatorGateway }
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
@@ -170,6 +176,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.operatorGateway)
+ self.refreshLastShareEventFromRelay()
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
// Route through the coordinator so VoiceWake and Talk don't fight over the microphone.
self.setTalkEnabled(talkEnabled)
@@ -466,6 +473,16 @@ final class NodeAppModel {
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
+ if let relay = ShareGatewayRelaySettings.loadConfig() {
+ ShareGatewayRelaySettings.saveConfig(
+ ShareGatewayRelayConfig(
+ gatewayURLString: relay.gatewayURLString,
+ token: relay.token,
+ password: relay.password,
+ sessionKey: self.mainSessionKey,
+ deliveryChannel: relay.deliveryChannel ?? self.shareDeliveryChannel,
+ deliveryTo: relay.deliveryTo ?? self.shareDeliveryTo))
+ }
}
func setGlobalWakeWords(_ words: [String]) async {
@@ -637,22 +654,33 @@ final class NodeAppModel {
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
+ self.deepLinkLogger.info(
+ "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
+ )
if message.count > 20000 {
self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)."
+ self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
return
}
guard await self.isGatewayConnected() else {
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
+ self.recordShareEvent("Failed: gateway not connected.")
+ self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
return
}
do {
try await self.sendAgentRequest(link: link)
self.screen.errorText = nil
+ self.recordShareEvent("Sent to gateway (\(message.count) chars).")
+ self.deepLinkLogger.info("agent deep link forwarded to gateway")
+ self.openChatRequestID &+= 1
} catch {
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
+ self.recordShareEvent("Failed: \(error.localizedDescription)")
+ self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -1455,8 +1483,9 @@ private extension NodeAppModel {
}
func isLocationPreciseEnabled() -> Bool {
- if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
- return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
+ // iOS settings now expose a single location mode control.
+ // Default location tool precision stays high unless a command explicitly requests balanced.
+ true
}
static func decodeParams(_ type: T.Type, from json: String?) throws -> T {
@@ -1584,6 +1613,7 @@ extension NodeAppModel {
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
+ ShareGatewayRelaySettings.clearConfig()
self.showLocalCanvasOnDisconnect()
}
}
@@ -1658,6 +1688,7 @@ private extension NodeAppModel {
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
+ await self.refreshShareRouteFromGateway()
await self.startVoiceWakeSync()
await MainActor.run { self.startGatewayHealthMonitor() }
},
@@ -1744,6 +1775,21 @@ private extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
}
+ let relayData = await MainActor.run {
+ (
+ sessionKey: self.mainSessionKey,
+ deliveryChannel: self.shareDeliveryChannel,
+ deliveryTo: self.shareDeliveryTo
+ )
+ }
+ ShareGatewayRelaySettings.saveConfig(
+ ShareGatewayRelayConfig(
+ gatewayURLString: url.absoluteString,
+ token: token,
+ password: password,
+ sessionKey: relayData.sessionKey,
+ deliveryChannel: relayData.deliveryChannel,
+ deliveryTo: relayData.deliveryTo))
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
if let addr = await self.nodeGateway.currentRemoteAddress() {
await MainActor.run { self.gatewayRemoteAddress = addr }
@@ -1831,9 +1877,9 @@ private extension NodeAppModel {
self.gatewayPairingRequestId = requestId
if let requestId, !requestId.isEmpty {
self.gatewayStatusText =
- "Pairing required (requestId: \(requestId)). Approve on gateway, then tap Resume."
+ "Pairing required (requestId: \(requestId)). Approve on gateway and return to OpenClaw."
} else {
- self.gatewayStatusText = "Pairing required. Approve on gateway, then tap Resume."
+ self.gatewayStatusText = "Pairing required. Approve on gateway and return to OpenClaw."
}
}
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
@@ -1901,6 +1947,96 @@ private extension NodeAppModel {
}
extension NodeAppModel {
+ private func refreshShareRouteFromGateway() async {
+ struct Params: Codable {
+ var includeGlobal: Bool
+ var includeUnknown: Bool
+ var limit: Int
+ }
+ struct SessionRow: Decodable {
+ var key: String
+ var updatedAt: Double?
+ var lastChannel: String?
+ var lastTo: String?
+ }
+ struct SessionsListResult: Decodable {
+ var sessions: [SessionRow]
+ }
+
+ let normalize: (String?) -> String? = { raw in
+ let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+
+ do {
+ let data = try JSONEncoder().encode(
+ Params(includeGlobal: true, includeUnknown: false, limit: 80))
+ guard let json = String(data: data, encoding: .utf8) else { return }
+ let response = try await self.operatorGateway.request(
+ method: "sessions.list",
+ paramsJSON: json,
+ timeoutSeconds: 10)
+ let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response)
+ let currentKey = self.mainSessionKey
+ let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
+ let exactMatch = sorted.first { row in
+ row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
+ }
+ let fallback = sorted.first { row in
+ normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
+ }
+ let selected = exactMatch ?? fallback
+ let channel = normalize(selected?.lastChannel)
+ let to = normalize(selected?.lastTo)
+
+ await MainActor.run {
+ self.shareDeliveryChannel = channel
+ self.shareDeliveryTo = to
+ if let relay = ShareGatewayRelaySettings.loadConfig() {
+ ShareGatewayRelaySettings.saveConfig(
+ ShareGatewayRelayConfig(
+ gatewayURLString: relay.gatewayURLString,
+ token: relay.token,
+ password: relay.password,
+ sessionKey: self.mainSessionKey,
+ deliveryChannel: channel,
+ deliveryTo: to))
+ }
+ }
+ } catch {
+ // Best-effort only.
+ }
+ }
+
+ func runSharePipelineSelfTest() async {
+ self.recordShareEvent("Share self-test running…")
+
+ let payload = SharedContentPayload(
+ title: "OpenClaw Share Self-Test",
+ url: URL(string: "https://openclaw.ai/share-self-test"),
+ text: "Validate iOS share->deep-link->gateway forwarding.")
+ guard let deepLink = ShareToAgentDeepLink.buildURL(
+ from: payload,
+ instruction: "Reply with: SHARE SELF-TEST OK")
+ else {
+ self.recordShareEvent("Self-test failed: could not build deep link.")
+ return
+ }
+
+ await self.handleDeepLink(url: deepLink)
+ }
+
+ func refreshLastShareEventFromRelay() {
+ if let event = ShareGatewayRelaySettings.loadLastEvent() {
+ self.lastShareEventText = event
+ }
+ }
+
+ func recordShareEvent(_ text: String) {
+ ShareGatewayRelaySettings.saveLastEvent(text)
+ self.refreshLastShareEventFromRelay()
+ }
+
func reloadTalkConfig() {
Task { [weak self] in
await self?.talkMode.reloadConfig()
diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
index cbe9db2575..f2d2cca635 100644
--- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
+++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
@@ -44,6 +44,7 @@ private enum OnboardingStep: Int, CaseIterable {
struct OnboardingWizardView: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
+ @Environment(\.scenePhase) private var scenePhase
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
@@ -66,6 +67,7 @@ struct OnboardingWizardView: View {
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var selectedPhoto: PhotosPickerItem?
+ @State private var lastPairingAutoResumeAttemptAt: Date?
let allowSkip: Bool
let onClose: () -> Void
@@ -276,6 +278,10 @@ struct OnboardingWizardView: View {
}
self.onClose()
}
+ .onChange(of: self.scenePhase) { _, newValue in
+ guard newValue == .active else { return }
+ self.attemptAutomaticPairingResumeIfNeeded()
+ }
}
@ViewBuilder
@@ -517,27 +523,38 @@ struct OnboardingWizardView: View {
if self.issue.needsPairing {
Section {
- Button("Copy: openclaw devices list") {
- UIPasteboard.general.string = "openclaw devices list"
- }
-
- if let id = self.issue.requestId {
- Button("Copy: openclaw devices approve \(id)") {
- UIPasteboard.general.string = "openclaw devices approve \(id)"
- }
- } else {
- Button("Copy: openclaw devices approve ") {
- UIPasteboard.general.string = "openclaw devices approve "
- }
+ Button {
+ self.resumeAfterPairingApproval()
+ } label: {
+ Label("Resume After Approval", systemImage: "arrow.clockwise")
}
+ .disabled(self.connectingGatewayID != nil)
} header: {
Text("Pairing Approval")
} footer: {
- Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
+ let requestLine: String = {
+ if let id = self.issue.requestId, !id.isEmpty {
+ return "Request ID: \(id)"
+ }
+ return "Request ID: check `openclaw devices list`."
+ }()
+ Text(
+ "Approve this device on the gateway.\n"
+ + "1) `openclaw devices approve` (or `openclaw devices approve `)\n"
+ + "2) `/pair approve` in Telegram\n"
+ + "\(requestLine)\n"
+ + "OpenClaw will also retry automatically when you return to this app.")
}
}
Section {
+ Button {
+ self.openQRScannerFromOnboarding()
+ } label: {
+ Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
+ }
+ .disabled(self.connectingGatewayID != nil)
+
Button {
Task { await self.retryLastAttempt() }
} label: {
@@ -549,20 +566,6 @@ struct OnboardingWizardView: View {
}
}
.disabled(self.connectingGatewayID != nil)
-
- Button {
- self.resumeAfterPairingApproval()
- } label: {
- Label("Resume After Approval", systemImage: "arrow.clockwise")
- }
- .disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
-
- Button {
- self.openQRScannerFromOnboarding()
- } label: {
- Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
- }
- .disabled(self.connectingGatewayID != nil)
}
}
}
@@ -677,6 +680,19 @@ struct OnboardingWizardView: View {
Task { await self.retryLastAttempt() }
}
+ private func attemptAutomaticPairingResumeIfNeeded() {
+ guard self.step == .auth else { return }
+ guard self.issue.needsPairing else { return }
+ guard self.connectingGatewayID == nil else { return }
+
+ let now = Date()
+ if let last = self.lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 {
+ return
+ }
+ self.lastPairingAutoResumeAttemptAt = now
+ self.resumeAfterPairingApproval()
+ }
+
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(
diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift
index 50b54e9dd8..70ba9cdb96 100644
--- a/apps/ios/Sources/RootCanvas.swift
+++ b/apps/ios/Sources/RootCanvas.swift
@@ -146,6 +146,9 @@ struct RootCanvas: View {
}
self.maybeAutoOpenSettings()
}
+ .onChange(of: self.appModel.openChatRequestID) { _, _ in
+ self.presentedSheet = .chat
+ }
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index 915c332554..7825b45cb8 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -6,6 +6,12 @@ import SwiftUI
import UIKit
struct SettingsTab: View {
+ private struct FeatureHelp: Identifiable {
+ let id = UUID()
+ let title: String
+ let message: String
+ }
+
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@@ -19,7 +25,6 @@ struct SettingsTab: View {
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
- @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@@ -37,11 +42,10 @@ struct SettingsTab: View {
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@State private var connectingGatewayID: String?
- @State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
- @State private var talkElevenLabsApiKey: String = ""
+ @State private var defaultShareInstruction: String = ""
@AppStorage("gateway.setupCode") private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualGatewayPortText: String = ""
@@ -49,6 +53,7 @@ struct SettingsTab: View {
@State private var selectedAgentPickerId: String = ""
@State private var showResetOnboardingAlert: Bool = false
+ @State private var activeFeatureHelp: FeatureHelp?
@State private var suppressCredentialPersist: Bool = false
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
@@ -243,30 +248,22 @@ struct SettingsTab: View {
Section("Device") {
DisclosureGroup("Features") {
- Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
- .onChange(of: self.voiceWakeEnabled) { _, newValue in
+ self.featureToggle(
+ "Voice Wake",
+ isOn: self.$voiceWakeEnabled,
+ help: "Enables wake-word activation to start a hands-free session.") { newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
- Toggle("Talk Mode", isOn: self.$talkEnabled)
- .onChange(of: self.talkEnabled) { _, newValue in
+ self.featureToggle(
+ "Talk Mode",
+ isOn: self.$talkEnabled,
+ help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
self.appModel.setTalkEnabled(newValue)
}
- SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- Toggle("Background Listening", isOn: self.$talkBackgroundEnabled)
- Text("Keep listening when the app is in the background. Uses more battery.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- Toggle("Voice Directive Hint", isOn: self.$talkVoiceDirectiveHintEnabled)
- Text("Include ElevenLabs voice switching instructions in the Talk Mode prompt. Disable to save tokens.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- // Keep this separate so users can hide the side bubble without disabling Talk Mode.
- Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
+ self.featureToggle(
+ "Background Listening",
+ isOn: self.$talkBackgroundEnabled,
+ help: "Keeps listening while the app is backgrounded. Uses more battery.")
NavigationLink {
VoiceWakeWordsSettingsView()
@@ -276,29 +273,78 @@ struct SettingsTab: View {
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
- Toggle("Allow Camera", isOn: self.$cameraEnabled)
- Text("Allows the gateway to request photos or short video clips (foreground only).")
- .font(.footnote)
- .foregroundStyle(.secondary)
+ self.featureToggle(
+ "Allow Camera",
+ isOn: self.$cameraEnabled,
+ help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
+ HStack(spacing: 8) {
+ Text("Location Access")
+ Spacer()
+ Button {
+ self.activeFeatureHelp = FeatureHelp(
+ title: "Location Access",
+ message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Location Access info")
+ }
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(OpenClawLocationMode.off.rawValue)
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always").tag(OpenClawLocationMode.always.rawValue)
}
+ .labelsHidden()
.pickerStyle(.segmented)
- Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
- .disabled(self.locationMode == .off)
+ self.featureToggle(
+ "Prevent Sleep",
+ isOn: self.$preventSleep,
+ help: "Keeps the screen awake while OpenClaw is open.")
- Text("Always requires system permission and may prompt to open Settings.")
- .font(.footnote)
- .foregroundStyle(.secondary)
+ DisclosureGroup("Advanced") {
+ self.featureToggle(
+ "Voice Directive Hint",
+ isOn: self.$talkVoiceDirectiveHintEnabled,
+ help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
+ self.featureToggle(
+ "Show Talk Button",
+ isOn: self.$talkButtonEnabled,
+ help: "Shows the floating Talk button in the main interface.")
+ TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
+ .lineLimit(2 ... 6)
+ .textInputAutocapitalization(.sentences)
+ HStack(spacing: 8) {
+ Text("Default Share Instruction")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button {
+ self.activeFeatureHelp = FeatureHelp(
+ title: "Default Share Instruction",
+ message: "Appends this instruction when sharing content into OpenClaw from iOS.")
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Default Share Instruction info")
+ }
- Toggle("Prevent Sleep", isOn: self.$preventSleep)
- Text("Keeps the screen awake while OpenClaw is open.")
- .font(.footnote)
- .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ Task { await self.appModel.runSharePipelineSelfTest() }
+ } label: {
+ Label("Run Share Self-Test", systemImage: "checkmark.seal")
+ }
+ Text(self.appModel.lastShareEventText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
}
DisclosureGroup("Device Info") {
@@ -306,19 +352,11 @@ struct SettingsTab: View {
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
- LabeledContent("IP", value: self.localIPAddress ?? "—")
- .contextMenu {
- if let ip = self.localIPAddress {
- Button {
- UIPasteboard.general.string = ip
- } label: {
- Label("Copy", systemImage: "doc.on.doc")
- }
- }
- }
+ .lineLimit(1)
+ .truncationMode(.middle)
+ LabeledContent("Device", value: self.deviceFamily())
LabeledContent("Platform", value: self.platformString())
- LabeledContent("Version", value: self.appVersion())
- LabeledContent("Model", value: self.modelIdentifier())
+ LabeledContent("OpenClaw", value: self.openClawVersionString())
}
}
}
@@ -342,8 +380,13 @@ struct SettingsTab: View {
Text(
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
}
+ .alert(item: self.$activeFeatureHelp) { help in
+ Alert(
+ title: Text(help.title),
+ message: Text(help.message),
+ dismissButton: .default(Text("OK")))
+ }
.onAppear {
- self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
self.syncManualPortText()
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -351,7 +394,8 @@ struct SettingsTab: View {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
- self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
+ self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
+ self.appModel.refreshLastShareEventFromRelay()
// Keep setup front-and-center when disconnected; keep things compact once connected.
self.gatewayExpanded = !self.isGatewayConnected
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
@@ -384,8 +428,8 @@ struct SettingsTab: View {
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
- .onChange(of: self.talkElevenLabsApiKey) { _, newValue in
- GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
+ .onChange(of: self.defaultShareInstruction) { _, newValue in
+ ShareToAgentSettings.saveDefaultInstruction(newValue)
}
.onChange(of: self.manualGatewayPort) { _, _ in
self.syncManualPortText()
@@ -518,14 +562,6 @@ struct SettingsTab: View {
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
- private var locationMode: OpenClawLocationMode {
- OpenClawLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
- }
-
- private func appVersion() -> String {
- Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
- }
-
private func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
@@ -537,14 +573,36 @@ struct SettingsTab: View {
}
}
- private func modelIdentifier() -> String {
- var systemInfo = utsname()
- uname(&systemInfo)
- let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
- String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
+ private func openClawVersionString() -> String {
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
+ let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
+ let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedBuild.isEmpty || trimmedBuild == version {
+ return version
+ }
+ return "\(version) (\(trimmedBuild))"
+ }
+
+ private func featureToggle(
+ _ title: String,
+ isOn: Binding,
+ help: String,
+ onChange: ((Bool) -> Void)? = nil
+ ) -> some View {
+ HStack(spacing: 8) {
+ Toggle(title, isOn: isOn)
+ Button {
+ self.activeFeatureHelp = FeatureHelp(title: title, message: help)
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("\(title) info")
+ }
+ .onChange(of: isOn.wrappedValue) { _, newValue in
+ onChange?(newValue)
}
- let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return trimmed.isEmpty ? "unknown" : trimmed
}
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
diff --git a/apps/ios/Tests/ShareToAgentDeepLinkTests.swift b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift
new file mode 100644
index 0000000000..4ea178ecfa
--- /dev/null
+++ b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift
@@ -0,0 +1,51 @@
+import OpenClawKit
+import Foundation
+import Testing
+
+@Suite struct ShareToAgentDeepLinkTests {
+ @Test func buildMessageIncludesSharedFields() {
+ let payload = SharedContentPayload(
+ title: "Article",
+ url: URL(string: "https://example.com/post")!,
+ text: "Read this")
+
+ let message = ShareToAgentDeepLink.buildMessage(
+ from: payload,
+ instruction: "Summarize and give next steps.")
+ #expect(message.contains("Shared from iOS."))
+ #expect(message.contains("Title: Article"))
+ #expect(message.contains("URL: https://example.com/post"))
+ #expect(message.contains("Text:\nRead this"))
+ #expect(message.contains("Summarize and give next steps."))
+ }
+
+ @Test func buildURLEncodesAgentRoute() {
+ let payload = SharedContentPayload(
+ title: "",
+ url: URL(string: "https://example.com")!,
+ text: nil)
+
+ let url = ShareToAgentDeepLink.buildURL(from: payload)
+ let parsed = url.flatMap { DeepLinkParser.parse($0) }
+ guard case let .agent(agent)? = parsed else {
+ Issue.record("Expected openclaw://agent deep link")
+ return
+ }
+
+ #expect(agent.thinking == "low")
+ #expect(agent.message.contains("https://example.com"))
+ }
+
+ @Test func buildURLReturnsNilWhenPayloadEmpty() {
+ let payload = SharedContentPayload(title: nil, url: nil, text: nil)
+ #expect(ShareToAgentDeepLink.buildURL(from: payload) == nil)
+ }
+
+ @Test func shareInstructionSettingsRoundTrip() {
+ let value = "Focus on booking constraints and alternatives."
+ ShareToAgentSettings.saveDefaultInstruction(value)
+ defer { ShareToAgentSettings.saveDefaultInstruction(nil) }
+
+ #expect(ShareToAgentSettings.loadDefaultInstruction() == value)
+ }
+}
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 8196c0f437..19913a5041 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -29,9 +29,14 @@ targets:
OpenClaw:
type: application
platform: iOS
+ configFiles:
+ Debug: Signing.xcconfig
+ Release: Signing.xcconfig
sources:
- path: Sources
dependencies:
+ - target: OpenClawShareExtension
+ embed: true
- package: OpenClawKit
- package: OpenClawKit
product: OpenClawChatUI
@@ -66,12 +71,13 @@ targets:
exit 1
fi
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
- configFiles:
- Debug: Config/Signing.xcconfig
- Release: Config/Signing.xcconfig
settings:
base:
- PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios
+ CODE_SIGN_IDENTITY: "Apple Development"
+ CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
+ DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
+ PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
+ PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_APPINTENTS_METADATA: NO
@@ -80,6 +86,10 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
+ CFBundleURLTypes:
+ - CFBundleURLName: ai.openclaw.ios
+ CFBundleURLSchemes:
+ - openclaw
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"
UILaunchScreen: {}
@@ -108,6 +118,28 @@ targets:
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
+ OpenClawShareExtension:
+ type: app-extension
+ platform: iOS
+ configFiles:
+ Debug: Signing.xcconfig
+ Release: Signing.xcconfig
+ sources:
+ - path: ShareExtension
+ dependencies:
+ - package: OpenClawKit
+ settings:
+ base:
+ CODE_SIGN_IDENTITY: "Apple Development"
+ CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
+ DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
+ PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
+ PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
+ SWIFT_VERSION: "6.0"
+ SWIFT_STRICT_CONCURRENCY: complete
+ info:
+ path: ShareExtension/Info.plist
+
OpenClawTests:
type: bundle.unit-test
platform: iOS
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift
new file mode 100644
index 0000000000..7b4c3864b3
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
+ public let gatewayURLString: String
+ public let token: String?
+ public let password: String?
+ public let sessionKey: String
+ public let deliveryChannel: String?
+ public let deliveryTo: String?
+
+ public init(
+ gatewayURLString: String,
+ token: String?,
+ password: String?,
+ sessionKey: String,
+ deliveryChannel: String? = nil,
+ deliveryTo: String? = nil)
+ {
+ self.gatewayURLString = gatewayURLString
+ self.token = token
+ self.password = password
+ self.sessionKey = sessionKey
+ self.deliveryChannel = deliveryChannel
+ self.deliveryTo = deliveryTo
+ }
+}
+
+public enum ShareGatewayRelaySettings {
+ private static let suiteName = "group.ai.openclaw.shared"
+ private static let relayConfigKey = "share.gatewayRelay.config.v1"
+ private static let lastEventKey = "share.gatewayRelay.event.v1"
+
+ private static var defaults: UserDefaults {
+ UserDefaults(suiteName: self.suiteName) ?? .standard
+ }
+
+ public static func loadConfig() -> ShareGatewayRelayConfig? {
+ guard let data = self.defaults.data(forKey: self.relayConfigKey) else { return nil }
+ return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data)
+ }
+
+ public static func saveConfig(_ config: ShareGatewayRelayConfig) {
+ guard let data = try? JSONEncoder().encode(config) else { return }
+ self.defaults.set(data, forKey: self.relayConfigKey)
+ }
+
+ public static func clearConfig() {
+ self.defaults.removeObject(forKey: self.relayConfigKey)
+ }
+
+ public static func saveLastEvent(_ message: String) {
+ let timestamp = ISO8601DateFormatter().string(from: Date())
+ let payload = "[\(timestamp)] \(message)"
+ self.defaults.set(payload, forKey: self.lastEventKey)
+ }
+
+ public static func loadLastEvent() -> String? {
+ let value = self.defaults.string(forKey: self.lastEventKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift
new file mode 100644
index 0000000000..08f0623433
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+public struct SharedContentPayload: Sendable, Equatable {
+ public let title: String?
+ public let url: URL?
+ public let text: String?
+
+ public init(title: String?, url: URL?, text: String?) {
+ self.title = title
+ self.url = url
+ self.text = text
+ }
+}
+
+public enum ShareToAgentDeepLink {
+ public static func buildURL(from payload: SharedContentPayload, instruction: String? = nil) -> URL? {
+ let message = self.buildMessage(from: payload, instruction: instruction)
+ guard !message.isEmpty else { return nil }
+
+ var components = URLComponents()
+ components.scheme = "openclaw"
+ components.host = "agent"
+ components.queryItems = [
+ URLQueryItem(name: "message", value: message),
+ URLQueryItem(name: "thinking", value: "low"),
+ ]
+ return components.url
+ }
+
+ public static func buildMessage(from payload: SharedContentPayload, instruction: String? = nil) -> String {
+ let title = self.clean(payload.title)
+ let text = self.clean(payload.text)
+ let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
+ let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
+
+ var lines: [String] = ["Shared from iOS."]
+ if let title, !title.isEmpty {
+ lines.append("Title: \(title)")
+ }
+ if let urlText, !urlText.isEmpty {
+ lines.append("URL: \(urlText)")
+ }
+ if let text, !text.isEmpty {
+ lines.append("Text:\n\(text)")
+ }
+ lines.append(resolvedInstruction)
+
+ let message = lines.joined(separator: "\n\n")
+ return self.limit(message, maxCharacters: 2400)
+ }
+
+ private static func clean(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func limit(_ value: String, maxCharacters: Int) -> String {
+ guard value.count > maxCharacters else { return value }
+ return String(value.prefix(maxCharacters))
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift
new file mode 100644
index 0000000000..9034dcfe1b
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public enum ShareToAgentSettings {
+ private static let suiteName = "group.ai.openclaw.shared"
+ private static let defaultInstructionKey = "share.defaultInstruction"
+ private static let fallbackInstruction = "Please help me with this."
+
+ private static var defaults: UserDefaults {
+ UserDefaults(suiteName: suiteName) ?? .standard
+ }
+
+ public static func loadDefaultInstruction() -> String {
+ let raw = self.defaults.string(forKey: self.defaultInstructionKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if let raw, !raw.isEmpty {
+ return raw
+ }
+ return self.fallbackInstruction
+ }
+
+ public static func saveDefaultInstruction(_ value: String?) {
+ let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmed.isEmpty {
+ self.defaults.removeObject(forKey: self.defaultInstructionKey)
+ return
+ }
+ self.defaults.set(trimmed, forKey: self.defaultInstructionKey)
+ }
+}
diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts
index 0cc379461f..52bc79b8c6 100644
--- a/src/cron/isolated-agent/run.skill-filter.test.ts
+++ b/src/cron/isolated-agent/run.skill-filter.test.ts
@@ -112,6 +112,7 @@ vi.mock("../../config/sessions.js", () => ({
vi.mock("../../routing/session-key.js", () => ({
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
normalizeAgentId: vi.fn((id: string) => id),
+ DEFAULT_ACCOUNT_ID: "default",
}));
vi.mock("../../infra/agent-events.js", () => ({
diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts
index 07525311a0..972159d43f 100644
--- a/src/gateway/server-node-events.test.ts
+++ b/src/gateway/server-node-events.test.ts
@@ -11,6 +11,7 @@ vi.mock("../commands/agent.js", () => ({
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
+ STATE_DIR: "/tmp/openclaw-state",
}));
vi.mock("../config/sessions.js", () => ({
updateSessionStore: vi.fn(),
diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts
index 267d53f7ec..05d2d59dc8 100644
--- a/src/gateway/server-node-events.ts
+++ b/src/gateway/server-node-events.ts
@@ -1,12 +1,19 @@
import { randomUUID } from "node:crypto";
+import { resolveSessionAgentId } from "../agents/agent-scope.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
+import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
import { agentCommand } from "../commands/agent.js";
import { loadConfig } from "../config/config.js";
+import { loadSessionStore } from "../config/sessions.js";
import { updateSessionStore } from "../config/sessions.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
+import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
+import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
+import { parseMessageWithAttachments } from "./chat-attachments.js";
+import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js";
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
import {
loadSessionEntry,
@@ -178,6 +185,45 @@ function queueSessionStoreTouch(params: {
});
}
+function resolveFallbackDeliveryRoute(params: {
+ storePath: LoadedSessionEntry["storePath"];
+ preferredChannel?: string;
+}): { channel?: string; to?: string } {
+ const { storePath, preferredChannel } = params;
+ if (!storePath) {
+ return {};
+ }
+
+ const targetChannel = preferredChannel?.trim().toLowerCase();
+ const store = loadSessionStore(storePath);
+ const candidates = Object.values(store)
+ .filter((entry) => {
+ if (!entry || typeof entry !== "object") {
+ return false;
+ }
+ const channel = typeof entry.lastChannel === "string" ? entry.lastChannel.trim() : "";
+ const to = typeof entry.lastTo === "string" ? entry.lastTo.trim() : "";
+ if (!channel || !to) {
+ return false;
+ }
+ if (targetChannel && channel.toLowerCase() !== targetChannel) {
+ return false;
+ }
+ return true;
+ })
+ .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+
+ const winner = candidates[0];
+ if (!winner) {
+ return {};
+ }
+
+ return {
+ channel: typeof winner.lastChannel === "string" ? winner.lastChannel.trim() : undefined,
+ to: typeof winner.lastTo === "string" ? winner.lastTo.trim() : undefined,
+ };
+}
+
function parseSessionKeyFromPayloadJSON(payloadJSON: string): string | null {
let payload: unknown;
try {
@@ -193,6 +239,35 @@ function parseSessionKeyFromPayloadJSON(payloadJSON: string): string | null {
return sessionKey.length > 0 ? sessionKey : null;
}
+async function sendReceiptAck(params: {
+ cfg: ReturnType;
+ deps: NodeEventContext["deps"];
+ sessionKey: string;
+ channel: string;
+ to: string;
+ text: string;
+}) {
+ const resolved = resolveOutboundTarget({
+ channel: params.channel,
+ to: params.to,
+ cfg: params.cfg,
+ mode: "explicit",
+ });
+ if (!resolved.ok) {
+ throw new Error(String(resolved.error));
+ }
+ const agentId = resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg });
+ await deliverOutboundPayloads({
+ cfg: params.cfg,
+ channel: params.channel,
+ to: resolved.to,
+ payloads: [{ text: params.text }],
+ agentId,
+ bestEffort: true,
+ deps: createOutboundSendDeps(params.deps),
+ });
+}
+
export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => {
switch (evt.event) {
case "voice.transcript": {
@@ -273,6 +348,14 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
sessionKey?: string | null;
thinking?: string | null;
deliver?: boolean;
+ attachments?: Array<{
+ type?: string;
+ mimeType?: string;
+ fileName?: string;
+ content?: unknown;
+ }> | null;
+ receipt?: boolean;
+ receiptText?: string | null;
to?: string | null;
channel?: string | null;
timeoutSeconds?: number | null;
@@ -284,7 +367,23 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
} catch {
return;
}
- const message = (link?.message ?? "").trim();
+ let message = (link?.message ?? "").trim();
+ const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(
+ link?.attachments ?? undefined,
+ );
+ let images: Array<{ type: "image"; data: string; mimeType: string }> = [];
+ if (normalizedAttachments.length > 0) {
+ try {
+ const parsed = await parseMessageWithAttachments(message, normalizedAttachments, {
+ maxBytes: 5_000_000,
+ log: ctx.logGateway,
+ });
+ message = parsed.message.trim();
+ images = parsed.images;
+ } catch {
+ return;
+ }
+ }
if (!message) {
return;
}
@@ -293,9 +392,13 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
}
const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : "";
- const channel = normalizeChannelId(channelRaw) ?? undefined;
- const to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
- const deliver = Boolean(link?.deliver) && Boolean(channel);
+ let channel = normalizeChannelId(channelRaw) ?? undefined;
+ let to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
+ const deliver = Boolean(link?.deliver);
+ const wantsReceipt = Boolean(link?.receipt);
+ const receiptTextRaw = typeof link?.receiptText === "string" ? link.receiptText.trim() : "";
+ const receiptText =
+ receiptTextRaw || "Just received your iOS share + request, working on it.";
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
@@ -305,9 +408,53 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
const sessionId = entry?.sessionId ?? randomUUID();
await touchSessionStore({ cfg, sessionKey, storePath, canonicalKey, entry, sessionId, now });
+ if (deliver && (!channel || !to)) {
+ const entryChannel =
+ typeof entry?.lastChannel === "string"
+ ? normalizeChannelId(entry.lastChannel)
+ : undefined;
+ const entryTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : "";
+ if (!channel && entryChannel) {
+ channel = entryChannel;
+ }
+ if (!to && entryTo) {
+ to = entryTo;
+ }
+ }
+ if (deliver && (!channel || !to)) {
+ const fallback = resolveFallbackDeliveryRoute({
+ storePath,
+ preferredChannel: channel ?? cfg.channels?.default ?? "telegram",
+ });
+ if (!channel && fallback.channel) {
+ channel = normalizeChannelId(fallback.channel) ?? channel;
+ }
+ if (!to && fallback.to) {
+ to = fallback.to;
+ }
+ }
+
+ if (wantsReceipt && channel && to) {
+ void sendReceiptAck({
+ cfg,
+ deps: ctx.deps,
+ sessionKey: canonicalKey,
+ channel,
+ to,
+ text: receiptText,
+ }).catch((err) => {
+ ctx.logGateway.warn(`agent receipt failed node=${nodeId}: ${formatForLog(err)}`);
+ });
+ } else if (wantsReceipt) {
+ ctx.logGateway.warn(
+ `agent receipt skipped node=${nodeId}: missing delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`,
+ );
+ }
+
void agentCommand(
{
message,
+ images,
sessionId,
sessionKey: canonicalKey,
thinking: link?.thinking ?? undefined,