mirror of
https://github.com/hanzoai/bot.git
synced 2026-10-30 17:24:26 +00:00
Three coordinated things:
1. Resolve the committed upstream/main merge conflicts. 94 files were
sitting with raw <<<<<<< HEAD ... >>>>>>> upstream/main blocks. Every
conflict had HanzoBot/Bot on HEAD and OpenClaw on upstream. Resolved
by keeping HEAD everywhere — 540 conflict blocks.
2. Rewrite remaining OpenClaw text refs.
OPENCLAW → HANZO_BOT
OpenClaw → HanzoBot (PascalCase type identifiers)
openclaw → bot (codebase convention — botToken,
botUsername etc.; avoids invalid
hyphenated JS identifiers)
openclaw[_]/openclaw[A-Z] → bot[_]/bot[A-Z] (compound)
ai.openclaw.x → ai.hanzo.bot.x (JVM package path)
3. Delete or rename openclaw-named file paths (116 of them). Dead
duplicates with a Bot-named canonical from the partial migration:
deleted. Otherwise renamed.
ai/openclaw/** → deleted (ai/hanzo/bot/** canonical)
Sources/OpenClaw*/ → deleted (Sources/Bot* canonical)
Tests/OpenClawIPCTests/ → deleted (Tests/BotIPCTests canonical)
OpenClawKit/ → deleted (BotKit canonical)
openclaw-tools.*.ts → deleted (bot-tools.*.ts canonical)
openclaw-root.ts etc. → deleted (bot-root.ts canonical)
types.openclaw.ts → deleted (types.bot.ts canonical)
extensions/*/openclaw.plugin.json → renamed hanzo-bot.plugin.json
docs/start/openclaw.md → renamed hanzo-bot.md (+ zh-CN)
docs/assets/openclaw-*.png, whatsapp-openclaw*.jpg → deleted
scripts/.../openclaw-* → deleted (bot/hanzo-bot parallels)
openclaw.mjs → deleted (hanzo-bot.mjs is package.bin)
Then patched 65 broken JS/TS import strings where hanzo-bot had ended
up inside an import path ('./types.hanzo-bot.js' → './types.bot.js'
etc., since the on-disk filename uses the brand-neutral bot- prefix).
Pre-existing lint debt cleaned up to get oxlint --type-aware to 0/0:
removed the dead i18n test that referenced a path no longer existing;
defined the missing DIDConfig and WalletConfig types in types.base.ts
(they were imported but never declared); dropped unused imports;
collapsed redundant type assertion and a tautological meta.bot lookup
that the openclaw→bot rename made redundant.
The a2ui.bundle.js generated artifact is kept at its pre-rebrand
contents — it gets regenerated by 'pnpm canvas:a2ui:bundle' from
sources I cannot rebuild in this commit; the source side is clean.
Verified: 0 occurrences of openclaw (case-insensitive) in tree source
content, 0 file or directory paths with openclaw in the name; oxlint
--type-aware src test reports 0/0.
285 lines
9.4 KiB
Swift
285 lines
9.4 KiB
Swift
import Foundation
|
|
import BotKit
|
|
import OSLog
|
|
@preconcurrency import WatchConnectivity
|
|
|
|
enum WatchMessagingError: LocalizedError {
|
|
case unsupported
|
|
case notPaired
|
|
case watchAppNotInstalled
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unsupported:
|
|
"WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device"
|
|
case .notPaired:
|
|
"WATCH_UNAVAILABLE: no paired Apple Watch"
|
|
case .watchAppNotInstalled:
|
|
"WATCH_UNAVAILABLE: Bot watch companion app is not installed"
|
|
}
|
|
}
|
|
}
|
|
|
|
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
|
private static let logger = Logger(subsystem: "ai.hanzo/bot", category: "watch.messaging")
|
|
private let session: WCSession?
|
|
private let replyHandlerLock = NSLock()
|
|
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
|
|
|
override init() {
|
|
if WCSession.isSupported() {
|
|
self.session = WCSession.default
|
|
} else {
|
|
self.session = nil
|
|
}
|
|
super.init()
|
|
if let session = self.session {
|
|
session.delegate = self
|
|
session.activate()
|
|
}
|
|
}
|
|
|
|
static func isSupportedOnDevice() -> Bool {
|
|
WCSession.isSupported()
|
|
}
|
|
|
|
static func currentStatusSnapshot() -> WatchMessagingStatus {
|
|
guard WCSession.isSupported() else {
|
|
return WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "unsupported")
|
|
}
|
|
let session = WCSession.default
|
|
return status(for: session)
|
|
}
|
|
|
|
func status() async -> WatchMessagingStatus {
|
|
await self.ensureActivated()
|
|
guard let session = self.session else {
|
|
return WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "unsupported")
|
|
}
|
|
return Self.status(for: session)
|
|
}
|
|
|
|
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
|
self.replyHandlerLock.lock()
|
|
self.replyHandler = handler
|
|
self.replyHandlerLock.unlock()
|
|
}
|
|
|
|
func sendNotification(
|
|
id: String,
|
|
params: BotWatchNotifyParams) async throws -> WatchNotificationSendResult
|
|
{
|
|
await self.ensureActivated()
|
|
guard let session = self.session else {
|
|
throw WatchMessagingError.unsupported
|
|
}
|
|
|
|
let snapshot = Self.status(for: session)
|
|
guard snapshot.paired else { throw WatchMessagingError.notPaired }
|
|
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
|
|
|
|
var payload: [String: Any] = [
|
|
"type": "watch.notify",
|
|
"id": id,
|
|
"title": params.title,
|
|
"body": params.body,
|
|
"priority": params.priority?.rawValue ?? BotNotificationPriority.active.rawValue,
|
|
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
|
|
]
|
|
if let promptId = Self.nonEmpty(params.promptId) {
|
|
payload["promptId"] = promptId
|
|
}
|
|
if let sessionKey = Self.nonEmpty(params.sessionKey) {
|
|
payload["sessionKey"] = sessionKey
|
|
}
|
|
if let kind = Self.nonEmpty(params.kind) {
|
|
payload["kind"] = kind
|
|
}
|
|
if let details = Self.nonEmpty(params.details) {
|
|
payload["details"] = details
|
|
}
|
|
if let expiresAtMs = params.expiresAtMs {
|
|
payload["expiresAtMs"] = expiresAtMs
|
|
}
|
|
if let risk = params.risk {
|
|
payload["risk"] = risk.rawValue
|
|
}
|
|
if let actions = params.actions, !actions.isEmpty {
|
|
payload["actions"] = actions.map { action in
|
|
var encoded: [String: Any] = [
|
|
"id": action.id,
|
|
"label": action.label,
|
|
]
|
|
if let style = Self.nonEmpty(action.style) {
|
|
encoded["style"] = style
|
|
}
|
|
return encoded
|
|
}
|
|
}
|
|
|
|
if snapshot.reachable {
|
|
do {
|
|
try await self.sendReachableMessage(payload, with: session)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: true,
|
|
queuedForDelivery: false,
|
|
transport: "sendMessage")
|
|
} catch {
|
|
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
_ = session.transferUserInfo(payload)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: false,
|
|
queuedForDelivery: true,
|
|
transport: "transferUserInfo")
|
|
}
|
|
|
|
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
session.sendMessage(
|
|
payload,
|
|
replyHandler: { _ in
|
|
continuation.resume()
|
|
},
|
|
errorHandler: { error in
|
|
continuation.resume(throwing: error)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func emitReply(_ event: WatchQuickReplyEvent) {
|
|
let handler: ((WatchQuickReplyEvent) -> Void)?
|
|
self.replyHandlerLock.lock()
|
|
handler = self.replyHandler
|
|
self.replyHandlerLock.unlock()
|
|
handler?(event)
|
|
}
|
|
|
|
private static func nonEmpty(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func parseQuickReplyPayload(
|
|
_ payload: [String: Any],
|
|
transport: String) -> WatchQuickReplyEvent?
|
|
{
|
|
guard (payload["type"] as? String) == "watch.reply" else {
|
|
return nil
|
|
}
|
|
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
|
return nil
|
|
}
|
|
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
|
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
|
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
|
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
|
let note = nonEmpty(payload["note"] as? String)
|
|
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
|
|
|
return WatchQuickReplyEvent(
|
|
replyId: replyId,
|
|
promptId: promptId,
|
|
actionId: actionId,
|
|
actionLabel: actionLabel,
|
|
sessionKey: sessionKey,
|
|
note: note,
|
|
sentAtMs: sentAtMs,
|
|
transport: transport)
|
|
}
|
|
|
|
private func ensureActivated() async {
|
|
guard let session = self.session else { return }
|
|
if session.activationState == .activated { return }
|
|
session.activate()
|
|
for _ in 0..<8 {
|
|
if session.activationState == .activated { return }
|
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
}
|
|
}
|
|
|
|
private static func status(for session: WCSession) -> WatchMessagingStatus {
|
|
WatchMessagingStatus(
|
|
supported: true,
|
|
paired: session.isPaired,
|
|
appInstalled: session.isWatchAppInstalled,
|
|
reachable: session.isReachable,
|
|
activationState: activationStateLabel(session.activationState))
|
|
}
|
|
|
|
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
|
switch state {
|
|
case .notActivated:
|
|
"notActivated"
|
|
case .inactive:
|
|
"inactive"
|
|
case .activated:
|
|
"activated"
|
|
@unknown default:
|
|
"unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WatchMessagingService: WCSessionDelegate {
|
|
func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: (any Error)?)
|
|
{
|
|
if let error {
|
|
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
|
return
|
|
}
|
|
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
|
}
|
|
|
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
|
|
|
func sessionDidDeactivate(_ session: WCSession) {
|
|
session.activate()
|
|
}
|
|
|
|
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
|
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
|
return
|
|
}
|
|
self.emitReply(event)
|
|
}
|
|
|
|
func session(
|
|
_: WCSession,
|
|
didReceiveMessage message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void)
|
|
{
|
|
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
|
replyHandler(["ok": false, "error": "unsupported_payload"])
|
|
return
|
|
}
|
|
replyHandler(["ok": true])
|
|
self.emitReply(event)
|
|
}
|
|
|
|
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
|
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
|
return
|
|
}
|
|
self.emitReply(event)
|
|
}
|
|
|
|
func sessionReachabilityDidChange(_ session: WCSession) {}
|
|
}
|