Merge branch 'steipete:main' into main
This commit is contained in:
commit
b92f70c52b
1
.gitignore
vendored
1
.gitignore
vendored
@ -45,3 +45,4 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@ -14,12 +14,14 @@
|
||||
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
||||
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
||||
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
|
||||
- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate).
|
||||
|
||||
### Features
|
||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
|
||||
- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.
|
||||
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
|
||||
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
||||
@ -43,11 +45,16 @@
|
||||
|
||||
### Fixes
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
|
||||
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
|
||||
- Agent prompt: remove hardcoded user name in system prompt example.
|
||||
- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).
|
||||
- Control UI: refine Web Chat session selector styling (chevron spacing + background).
|
||||
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
||||
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
||||
- Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).
|
||||
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
|
||||
- WhatsApp auto-reply: default to self-only when no config is present.
|
||||
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
||||
- Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.
|
||||
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
||||
@ -61,10 +68,17 @@
|
||||
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
||||
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
||||
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
||||
- CLI onboarding: always prompt for WhatsApp `routing.allowFrom` and print (optionally open) the Control UI URL when done.
|
||||
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
|
||||
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
||||
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
||||
- macOS: keep config writes on the main actor to satisfy Swift concurrency rules.
|
||||
- macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect.
|
||||
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
|
||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
|
||||
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
|
||||
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
|
||||
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
|
||||
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
||||
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
|
||||
@ -116,6 +130,7 @@
|
||||
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
||||
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
||||
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
||||
- Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.
|
||||
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
||||
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
||||
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
||||
|
||||
@ -157,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
@ -166,7 +166,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
### WhatsApp
|
||||
|
||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
|
||||
### Telegram
|
||||
|
||||
@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
### Discord
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@ -62,6 +62,7 @@ actor CameraCaptureService {
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
await self.waitForExposureAndWhiteBalance(device: device)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
@ -257,6 +258,17 @@ actor CameraCaptureService {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
||||
let stepNs: UInt64 = 50_000_000
|
||||
let maxSteps = 30 // ~1.5s
|
||||
for _ in 0..<maxSteps {
|
||||
if !(device.isAdjustingExposure || device.isAdjustingWhiteBalance) {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: stepNs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
|
||||
@ -97,4 +97,13 @@ enum ClawdisConfigFile {
|
||||
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
||||
}
|
||||
|
||||
static func gatewayPassword() -> String? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return remote["password"] as? String
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -431,12 +431,57 @@ struct ConfigSettings: View {
|
||||
self.configSaving = true
|
||||
defer { self.configSaving = false }
|
||||
|
||||
let configModel = self.configModel
|
||||
let customModel = self.customModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
let browserControlUrl = self.browserControlUrl
|
||||
let browserColorHex = self.browserColorHex
|
||||
let browserAttachOnly = self.browserAttachOnly
|
||||
let talkVoiceId = self.talkVoiceId
|
||||
let talkApiKey = self.talkApiKey
|
||||
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
||||
|
||||
let errorMessage = await ConfigSettings.buildAndSaveConfig(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
browserControlUrl: browserControlUrl,
|
||||
browserColorHex: browserColorHex,
|
||||
browserAttachOnly: browserAttachOnly,
|
||||
talkVoiceId: talkVoiceId,
|
||||
talkApiKey: talkApiKey,
|
||||
talkInterruptOnSpeech: talkInterruptOnSpeech
|
||||
)
|
||||
|
||||
if let errorMessage {
|
||||
self.modelError = errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveConfig(
|
||||
configModel: String,
|
||||
customModel: String,
|
||||
heartbeatMinutes: Int?,
|
||||
heartbeatBody: String,
|
||||
browserEnabled: Bool,
|
||||
browserControlUrl: String,
|
||||
browserColorHex: String,
|
||||
browserAttachOnly: Bool,
|
||||
talkVoiceId: String,
|
||||
talkApiKey: String,
|
||||
talkInterruptOnSpeech: Bool
|
||||
) async -> String? {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||||
let chosenModel = (configModel == "__custom__" ? customModel : configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||
@ -445,40 +490,41 @@ struct ConfigSettings: View {
|
||||
agent["heartbeatMinutes"] = heartbeatMinutes
|
||||
}
|
||||
|
||||
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedBody = heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
agent["heartbeatBody"] = trimmedBody
|
||||
}
|
||||
|
||||
root["agent"] = agent
|
||||
|
||||
browser["enabled"] = self.browserEnabled
|
||||
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
browser["enabled"] = browserEnabled
|
||||
let trimmedUrl = browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||
let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedColor = browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||
browser["attachOnly"] = self.browserAttachOnly
|
||||
browser["attachOnly"] = browserAttachOnly
|
||||
root["browser"] = browser
|
||||
|
||||
let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedVoice = talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedVoice.isEmpty {
|
||||
talk.removeValue(forKey: "voiceId")
|
||||
} else {
|
||||
talk["voiceId"] = trimmedVoice
|
||||
}
|
||||
let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedApiKey = talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedApiKey.isEmpty {
|
||||
talk.removeValue(forKey: "apiKey")
|
||||
} else {
|
||||
talk["apiKey"] = trimmedApiKey
|
||||
}
|
||||
talk["interruptOnSpeech"] = self.talkInterruptOnSpeech
|
||||
talk["interruptOnSpeech"] = talkInterruptOnSpeech
|
||||
root["talk"] = talk
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
return nil
|
||||
} catch let error {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ enum ConfigStore {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func save(_ root: [String: Any]) async throws {
|
||||
static func save(_ root: sending [String: Any]) async throws {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
if await self.isRemoteMode() {
|
||||
if let override = overrides.saveRemote {
|
||||
|
||||
@ -63,9 +63,11 @@ final class ControlChannel {
|
||||
self.logger.info("control channel state -> connecting")
|
||||
case .disconnected:
|
||||
self.logger.info("control channel state -> disconnected")
|
||||
self.scheduleRecovery(reason: "disconnected")
|
||||
case let .degraded(message):
|
||||
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
||||
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
||||
self.scheduleRecovery(reason: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -74,6 +76,8 @@ final class ControlChannel {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var recoveryTask: Task<Void, Never>?
|
||||
private var lastRecoveryAt: Date?
|
||||
|
||||
private init() {
|
||||
self.startEventStream()
|
||||
@ -231,7 +235,43 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||
return "Gateway error: \(detail)"
|
||||
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
|
||||
return "Gateway error: \(trimmed)"
|
||||
}
|
||||
|
||||
private func scheduleRecovery(reason: String) {
|
||||
let now = Date()
|
||||
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
|
||||
guard self.recoveryTask == nil else { return }
|
||||
self.lastRecoveryAt = now
|
||||
|
||||
self.recoveryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
||||
guard mode != .unconfigured else {
|
||||
self.recoveryTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
|
||||
self.logger.info(
|
||||
"control channel recovery starting mode=\(String(describing: mode), privacy: .public) reason=\(reasonText, privacy: .public)")
|
||||
if mode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
|
||||
@ -66,6 +66,7 @@ actor GatewayChannelActor {
|
||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||
private var url: URL
|
||||
private var token: String?
|
||||
private var password: String?
|
||||
private let session: WebSocketSessioning
|
||||
private var backoffMs: Double = 500
|
||||
private var shouldReconnect = true
|
||||
@ -82,11 +83,13 @@ actor GatewayChannelActor {
|
||||
init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String? = nil,
|
||||
session: WebSocketSessionBox? = nil,
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
|
||||
{
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.session = session?.session ?? URLSession(configuration: .default)
|
||||
self.pushHandler = pushHandler
|
||||
Task { [weak self] in
|
||||
@ -214,6 +217,8 @@ actor GatewayChannelActor {
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
|
||||
@ -40,7 +40,7 @@ struct GatewayAgentInvocation: Sendable {
|
||||
actor GatewayConnection {
|
||||
static let shared = GatewayConnection()
|
||||
|
||||
typealias Config = (url: URL, token: String?)
|
||||
typealias Config = (url: URL, token: String?, password: String?)
|
||||
|
||||
enum Method: String, Sendable {
|
||||
case agent
|
||||
@ -83,6 +83,7 @@ actor GatewayConnection {
|
||||
private var client: GatewayChannelActor?
|
||||
private var configuredURL: URL?
|
||||
private var configuredToken: String?
|
||||
private var configuredPassword: String?
|
||||
|
||||
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||
private var lastSnapshot: HelloOk?
|
||||
@ -103,7 +104,7 @@ actor GatewayConnection {
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard let client else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
@ -149,7 +150,7 @@ actor GatewayConnection {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard let client = self.client else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
@ -209,7 +210,7 @@ actor GatewayConnection {
|
||||
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||
func refresh() async throws {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
@ -264,8 +265,8 @@ actor GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token {
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
|
||||
return
|
||||
}
|
||||
if let client {
|
||||
@ -275,12 +276,14 @@ actor GatewayConnection {
|
||||
self.client = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
session: self.sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handle(push: push)
|
||||
})
|
||||
self.configuredURL = url
|
||||
self.configuredToken = token
|
||||
self.configuredPassword = password
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
import OSLog
|
||||
|
||||
enum GatewayEndpointState: Sendable, Equatable {
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?)
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
|
||||
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ actor GatewayEndpointStore {
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
let token: @Sendable () -> String?
|
||||
let password: @Sendable () -> String?
|
||||
let localPort: @Sendable () -> Int
|
||||
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||
@ -24,11 +25,52 @@ actor GatewayEndpointStore {
|
||||
static let live = Deps(
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
|
||||
password: {
|
||||
let root = ClawdisConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||
}
|
||||
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pw.isEmpty {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pw.isEmpty {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
|
||||
|
||||
@ -47,9 +89,11 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
let port = deps.localPort()
|
||||
let token = deps.token()
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
case .local:
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
case .unconfigured:
|
||||
@ -77,17 +121,18 @@ actor GatewayEndpointStore {
|
||||
|
||||
func setMode(_ mode: AppState.ConnectionMode) async {
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token))
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
return
|
||||
}
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||
case .unconfigured:
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
@ -110,8 +155,8 @@ actor GatewayEndpointStore {
|
||||
func requireConfig() async throws -> GatewayConnection.Config {
|
||||
await self.refresh()
|
||||
switch self.state {
|
||||
case let .ready(_, url, token):
|
||||
return (url, token)
|
||||
case let .ready(_, url, token, password):
|
||||
return (url, token, password)
|
||||
case let .unavailable(mode, reason):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||
@ -122,9 +167,10 @@ actor GatewayEndpointStore {
|
||||
do {
|
||||
let forwarded = try await self.deps.ensureRemoteTunnel()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token))
|
||||
return (url, token)
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch {
|
||||
let msg = "\(reason) (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
@ -144,7 +190,7 @@ actor GatewayEndpointStore {
|
||||
continuation.yield(next)
|
||||
}
|
||||
switch next {
|
||||
case let .ready(mode, url, _):
|
||||
case let .ready(mode, url, _, _):
|
||||
let modeDesc = String(describing: mode)
|
||||
let urlDesc = url.absoluteString
|
||||
self.logger
|
||||
@ -158,3 +204,15 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -64,6 +64,7 @@ enum GatewayLaunchAgentManager {
|
||||
.joined(separator: ":")
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
@ -71,9 +72,17 @@ enum GatewayLaunchAgentManager {
|
||||
<string>sips</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
envEntries += """
|
||||
<key>CLAWDIS_GATEWAY_TOKEN</key>
|
||||
<string>\(token)</string>
|
||||
<string>\(escapedToken)</string>
|
||||
"""
|
||||
}
|
||||
if let password {
|
||||
let escapedPassword = self.escapePlistValue(password)
|
||||
envEntries += """
|
||||
<key>CLAWDIS_GATEWAY_PASSWORD</key>
|
||||
<string>\(escapedPassword)</string>
|
||||
"""
|
||||
}
|
||||
let plist = """
|
||||
@ -146,6 +155,33 @@ enum GatewayLaunchAgentManager {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
// First check environment variable
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
// Then check config file (gateway.auth.password)
|
||||
let root = ClawdisConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func escapePlistValue(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct LaunchctlResult {
|
||||
let status: Int32
|
||||
let output: String
|
||||
@ -190,5 +226,9 @@ extension GatewayLaunchAgentManager {
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
}
|
||||
|
||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||
self.escapePlistValue(raw)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -114,6 +114,7 @@ final class HealthStore {
|
||||
guard !self.isRefreshing else { return }
|
||||
self.isRefreshing = true
|
||||
defer { self.isRefreshing = false }
|
||||
let previousError = self.lastError
|
||||
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||
@ -121,13 +122,23 @@ final class HealthStore {
|
||||
self.snapshot = decoded
|
||||
self.lastSuccess = Date()
|
||||
self.lastError = nil
|
||||
if previousError != nil {
|
||||
Self.logger.info("health refresh recovered")
|
||||
}
|
||||
} else {
|
||||
self.lastError = "health output not JSON"
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != self.lastError {
|
||||
Self.logger.warning("health refresh failed: output not JSON")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
let desc = error.localizedDescription
|
||||
self.lastError = desc
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != desc {
|
||||
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -164,14 +164,24 @@ struct MenuContent: View {
|
||||
}
|
||||
|
||||
private func saveBrowserControlEnabled(_ enabled: Bool) async {
|
||||
let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled)
|
||||
|
||||
if !success {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||
var root = await ConfigStore.load()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
root["browser"] = browser
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, ())
|
||||
} catch {
|
||||
await self.loadBrowserControlEnabled()
|
||||
return (false, ())
|
||||
}
|
||||
}
|
||||
|
||||
@ -365,6 +375,10 @@ struct MenuContent: View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(self.makeMessageItem(
|
||||
text: self.controlChannelStatusText,
|
||||
symbolName: "wifi.slash",
|
||||
width: width), at: insertIndex)
|
||||
return
|
||||
}
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
guard let snapshot = self.cachedSnapshot else {
|
||||
let headerItem = NSMenuItem()
|
||||
@ -195,17 +189,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
menu.insertItem(topSeparator, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
|
||||
at: cursor)
|
||||
if let gatewayEntry = self.gatewayEntry() {
|
||||
let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
|
||||
menu.insertItem(gatewayItem, at: cursor)
|
||||
cursor += 1
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.nodesTag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
@ -229,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
cursor += 1
|
||||
} else {
|
||||
for entry in entries.prefix(8) {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
let item = self.makeNodeItem(entry: entry, width: width)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
@ -265,27 +248,56 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
private var controlChannelStatusText: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected:
|
||||
return "Connected"
|
||||
case .connecting:
|
||||
return "Connecting to gateway…"
|
||||
case let .degraded(reason):
|
||||
if self.shouldShowConnecting { return "Connecting to gateway…" }
|
||||
return reason.nonEmpty ?? "No connection to gateway"
|
||||
case .disconnected:
|
||||
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
var host: String?
|
||||
var platform: String?
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
}
|
||||
|
||||
return NodeInfo(
|
||||
nodeId: "gateway",
|
||||
displayName: "Gateway",
|
||||
platform: platform,
|
||||
version: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
remoteIp: host,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: nil,
|
||||
connected: isConnected)
|
||||
}
|
||||
|
||||
private var shouldShowConnecting: Bool {
|
||||
switch GatewayProcessManager.shared.status {
|
||||
case .starting, .running, .attachedExisting:
|
||||
return true
|
||||
case .stopped, .failed:
|
||||
return false
|
||||
}
|
||||
private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||
@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
Label(text, systemImage: symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@ -2,15 +2,30 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct NodeMenuEntryFormatter {
|
||||
static func isGateway(_ entry: NodeInfo) -> Bool {
|
||||
entry.nodeId == "gateway"
|
||||
}
|
||||
|
||||
static func isConnected(_ entry: NodeInfo) -> Bool {
|
||||
entry.isConnected
|
||||
}
|
||||
|
||||
static func primaryName(_ entry: NodeInfo) -> String {
|
||||
entry.displayName?.nonEmpty ?? entry.nodeId
|
||||
if self.isGateway(entry) {
|
||||
return entry.displayName?.nonEmpty ?? "Gateway"
|
||||
}
|
||||
return entry.displayName?.nonEmpty ?? entry.nodeId
|
||||
}
|
||||
|
||||
static func summaryText(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
let role = self.roleText(entry)
|
||||
let name = self.primaryName(entry)
|
||||
var parts = ["\(name) · \(role)"]
|
||||
if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") }
|
||||
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
let name = self.primaryName(entry)
|
||||
var prefix = "Node: \(name)"
|
||||
if let ip = entry.remoteIp?.nonEmpty {
|
||||
@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
return self.safeSystemSymbol(
|
||||
"antenna.radiowaves.left.and.right",
|
||||
fallback: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") {
|
||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||
|
||||
@ -75,10 +75,26 @@ final class NodesStore {
|
||||
self.lastError = nil
|
||||
self.statusMessage = nil
|
||||
} catch {
|
||||
if Self.isCancelled(error) {
|
||||
self.logger.debug("node.list cancelled; keeping last nodes")
|
||||
if self.nodes.isEmpty {
|
||||
self.statusMessage = "Refreshing devices…"
|
||||
}
|
||||
self.lastError = nil
|
||||
return
|
||||
}
|
||||
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
|
||||
self.nodes = []
|
||||
self.lastError = error.localizedDescription
|
||||
self.statusMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isCancelled(_ error: Error) -> Bool {
|
||||
if error is CancellationError { return true }
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,16 @@ extension OnboardingView {
|
||||
|
||||
@discardableResult
|
||||
func saveAgentWorkspace(_ workspace: String?) async -> Bool {
|
||||
let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace)
|
||||
|
||||
if let errorMessage {
|
||||
self.workspaceStatus = errorMessage
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@ -90,10 +100,10 @@ extension OnboardingView {
|
||||
}
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return true
|
||||
} catch {
|
||||
self.workspaceStatus = "Failed to save config: \(error.localizedDescription)"
|
||||
return false
|
||||
return (true, nil)
|
||||
} catch let error {
|
||||
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
||||
return (false, errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,8 @@ enum PermissionManager {
|
||||
await self.ensureMicrophone(interactive: interactive)
|
||||
case .speechRecognition:
|
||||
await self.ensureSpeechRecognition(interactive: interactive)
|
||||
case .camera:
|
||||
await self.ensureCamera(interactive: interactive)
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,6 +116,24 @@ enum PermissionManager {
|
||||
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
}
|
||||
|
||||
private static func ensureCamera(interactive: Bool) async -> Bool {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
return await AVCaptureDevice.requestAccess(for: .video)
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
CameraPermissionHelper.openSettings()
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func voiceWakePermissionsGranted() -> Bool {
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
@ -153,6 +173,9 @@ enum PermissionManager {
|
||||
|
||||
case .speechRecognition:
|
||||
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
|
||||
case .camera:
|
||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
}
|
||||
}
|
||||
return results
|
||||
@ -189,6 +212,21 @@ enum MicrophonePermissionHelper {
|
||||
}
|
||||
}
|
||||
|
||||
enum CameraPermissionHelper {
|
||||
static func openSettings() {
|
||||
let candidates = [
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
||||
"x-apple.systempreferences:com.apple.preference.security",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppleScriptPermission {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")
|
||||
|
||||
|
||||
@ -120,6 +120,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "Screen Recording"
|
||||
case .microphone: "Microphone"
|
||||
case .speechRecognition: "Speech Recognition"
|
||||
case .camera: "Camera"
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,6 +133,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "Capture the screen for context or screenshots"
|
||||
case .microphone: "Allow Voice Wake and audio capture"
|
||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||
case .camera: "Capture photos and video from the camera"
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,6 +145,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "display"
|
||||
case .microphone: "mic"
|
||||
case .speechRecognition: "waveform"
|
||||
case .camera: "camera"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -280,28 +280,57 @@ struct TailscaleIntegrationSection: View {
|
||||
return
|
||||
}
|
||||
|
||||
let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: self.tailscaleMode,
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: trimmedPassword,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused
|
||||
)
|
||||
|
||||
if !success, let errorMessage {
|
||||
self.statusMessage = errorMessage
|
||||
return
|
||||
}
|
||||
|
||||
if self.connectionMode == .local, !self.isPaused {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
|
||||
} else {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
|
||||
}
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: GatewayTailscaleMode,
|
||||
requireCredentialsForServe: Bool,
|
||||
password: String,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool
|
||||
) async -> (Bool, String?) {
|
||||
var root = await ConfigStore.load()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
tailscale["mode"] = self.tailscaleMode.rawValue
|
||||
tailscale["mode"] = tailscaleMode.rawValue
|
||||
gateway["tailscale"] = tailscale
|
||||
|
||||
if self.tailscaleMode != .off {
|
||||
if tailscaleMode != .off {
|
||||
gateway["bind"] = "loopback"
|
||||
}
|
||||
|
||||
if self.tailscaleMode == .off {
|
||||
if tailscaleMode == .off {
|
||||
gateway.removeValue(forKey: "auth")
|
||||
} else {
|
||||
var auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
if self.tailscaleMode == .serve, !self.requireCredentialsForServe {
|
||||
if tailscaleMode == .serve, !requireCredentialsForServe {
|
||||
auth["allowTailscale"] = true
|
||||
auth.removeValue(forKey: "mode")
|
||||
auth.removeValue(forKey: "password")
|
||||
} else {
|
||||
auth["allowTailscale"] = false
|
||||
auth["mode"] = "password"
|
||||
auth["password"] = trimmedPassword
|
||||
auth["password"] = password
|
||||
}
|
||||
|
||||
if auth.isEmpty {
|
||||
@ -319,17 +348,10 @@ struct TailscaleIntegrationSection: View {
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
return
|
||||
return (true, nil)
|
||||
} catch let error {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
|
||||
if self.connectionMode == .local, !self.isPaused {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
|
||||
} else {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
|
||||
}
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
|
||||
private func restartGatewayIfNeeded() {
|
||||
|
||||
@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
||||
case screenRecording
|
||||
case microphone
|
||||
case speechRecognition
|
||||
case camera
|
||||
}
|
||||
|
||||
public enum CameraFacing: String, Codable, Sendable {
|
||||
|
||||
@ -29,6 +29,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { "t" },
|
||||
password: { nil },
|
||||
localPort: { 1234 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
@ -44,6 +45,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { nil },
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
@ -58,6 +60,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { "tok" },
|
||||
password: { "pw" },
|
||||
localPort: { 1 },
|
||||
remotePortIfRunning: { 5555 },
|
||||
ensureRemoteTunnel: { 5555 }))
|
||||
@ -69,13 +72,71 @@ import Testing
|
||||
_ = try await store.ensureRemoteControlTunnel()
|
||||
|
||||
let next = await iterator.next()
|
||||
guard case let .ready(mode, url, token) = next else {
|
||||
guard case let .ready(mode, url, token, password) = next else {
|
||||
Issue.record("expected .ready after ensure, got \(String(describing: next))")
|
||||
return
|
||||
}
|
||||
#expect(mode == .remote)
|
||||
#expect(url.absoluteString == "ws://127.0.0.1:5555")
|
||||
#expect(token == "tok")
|
||||
#expect(password == "pw")
|
||||
}
|
||||
|
||||
@Test func resolvesGatewayPasswordByMode() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": " local "],
|
||||
"remote": ["password": " remote "],
|
||||
],
|
||||
]
|
||||
let env: [String: String] = [:]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == "local")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == "remote")
|
||||
}
|
||||
|
||||
@Test func gatewayPasswordEnvOverridesConfig() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": "local"],
|
||||
"remote": ["password": "remote"],
|
||||
],
|
||||
]
|
||||
let env = ["CLAWDIS_GATEWAY_PASSWORD": " env "]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == "env")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == "env")
|
||||
}
|
||||
|
||||
@Test func gatewayPasswordIgnoresWhitespaceValues() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": " "],
|
||||
"remote": ["password": "\n\t"],
|
||||
],
|
||||
]
|
||||
let env = ["CLAWDIS_GATEWAY_PASSWORD": " "]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == nil)
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == nil)
|
||||
}
|
||||
|
||||
@Test func unconfiguredModeRejectsConfig() async {
|
||||
@ -83,6 +144,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { nil },
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
|
||||
@ -114,6 +114,9 @@ struct LowCoverageHelperTests {
|
||||
setenv(keyToken, " secret ", 1)
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||
#expect(
|
||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||
"a&b<c>"'")
|
||||
|
||||
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdis")
|
||||
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
||||
|
||||
@ -76,7 +76,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che
|
||||
|
||||
At minimum, set:
|
||||
- `agent.workspace`
|
||||
- `routing.allowFrom` (strongly recommended)
|
||||
- `whatsapp.allowFrom` (strongly recommended)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ You’re putting an agent in a position to:
|
||||
- send messages back out via WhatsApp/Telegram/Discord
|
||||
|
||||
Start conservative:
|
||||
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
- Use a dedicated WhatsApp number for the assistant.
|
||||
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
||||
|
||||
@ -74,7 +74,7 @@ clawdis gateway --port 18789
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"]
|
||||
}
|
||||
}
|
||||
@ -124,8 +124,10 @@ Example:
|
||||
// Start with 0; enable later.
|
||||
heartbeat: { every: "0m" }
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"]
|
||||
},
|
||||
routing: {
|
||||
allowFrom: ["+15555550123"],
|
||||
groupChat: {
|
||||
requireMention: true,
|
||||
mentionPatterns: ["@clawd", "clawd"]
|
||||
|
||||
@ -9,7 +9,7 @@ read_when:
|
||||
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed).
|
||||
|
||||
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
||||
- restrict who can trigger the bot (`routing.allowFrom`)
|
||||
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
|
||||
- tune group mention behavior (`routing.groupChat`)
|
||||
- customize message prefixes (`messages`)
|
||||
- set the agent’s workspace (`agent.workspace`)
|
||||
@ -21,7 +21,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
|
||||
```json5
|
||||
{
|
||||
agent: { workspace: "~/clawd" },
|
||||
routing: { allowFrom: ["+15555550123"] }
|
||||
whatsapp: { allowFrom: ["+15555550123"] }
|
||||
}
|
||||
```
|
||||
|
||||
@ -76,13 +76,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.allowFrom`
|
||||
### `whatsapp.allowFrom`
|
||||
|
||||
Allowlist of E.164 phone numbers that may trigger auto-replies.
|
||||
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: { allowFrom: ["+15555550123", "+447700900123"] }
|
||||
whatsapp: { allowFrom: ["+15555550123", "+447700900123"] }
|
||||
}
|
||||
```
|
||||
|
||||
@ -175,6 +175,12 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
token: "your-bot-token",
|
||||
mediaMaxMb: 8, // clamp inbound media size
|
||||
enableReactions: true, // allow agent-triggered reactions
|
||||
slashCommand: { // user-installed app slash commands
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeral: true
|
||||
},
|
||||
dm: {
|
||||
enabled: true, // disable all DMs when false
|
||||
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
|
||||
@ -549,7 +555,7 @@ Defaults:
|
||||
mode: "local", // or "remote"
|
||||
bind: "loopback",
|
||||
// controlUi: { enabled: true }
|
||||
// auth: { mode: "token" | "password" }
|
||||
// auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
|
||||
// tailscale: { mode: "off" | "serve" | "funnel" }
|
||||
}
|
||||
}
|
||||
@ -560,6 +566,7 @@ Notes:
|
||||
|
||||
Auth and Tailscale:
|
||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
|
||||
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
||||
|
||||
@ -25,8 +25,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||
7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs.
|
||||
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||
9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||
10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
|
||||
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||
|
||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
@ -47,6 +48,12 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
token: "abc.123",
|
||||
mediaMaxMb: 8,
|
||||
enableReactions: true,
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeral: true
|
||||
},
|
||||
dm: {
|
||||
enabled: true,
|
||||
allowFrom: ["123456789012345678", "steipete"],
|
||||
@ -77,10 +84,17 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
||||
|
||||
Slash command notes:
|
||||
- Register a chat input command in Discord with at least one string option (e.g., `prompt`).
|
||||
- The first non-empty string option is treated as the prompt.
|
||||
- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
|
||||
- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist.
|
||||
|
||||
## Reactions
|
||||
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
||||
- `action: "react"`
|
||||
|
||||
36
docs/doctor.md
Normal file
36
docs/doctor.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
summary: "Doctor command: health checks, config migrations, and repair steps"
|
||||
read_when:
|
||||
- Adding or modifying doctor migrations
|
||||
- Introducing breaking config changes
|
||||
---
|
||||
# Doctor
|
||||
|
||||
`clawdis doctor` is the repair + migration tool for Clawdis. It runs a quick health check, audits skills, and can migrate deprecated config entries to the new schema.
|
||||
|
||||
## What it does
|
||||
- Runs a health check and offers to restart the gateway if it looks unhealthy.
|
||||
- Prints a skills status summary (eligible/missing/blocked).
|
||||
- Detects deprecated config keys and offers to migrate them.
|
||||
|
||||
## Legacy config migrations
|
||||
When the config contains deprecated keys, other commands will refuse to run and ask you to run `clawdis doctor`.
|
||||
Doctor will:
|
||||
- Explain which legacy keys were found.
|
||||
- Show the migration it applied.
|
||||
- Rewrite `~/.clawdis/clawdis.json` with the updated schema.
|
||||
|
||||
Current migrations:
|
||||
- `routing.allowFrom` → `whatsapp.allowFrom`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
clawdis doctor
|
||||
```
|
||||
|
||||
If you want to review changes before writing, open the config file first:
|
||||
|
||||
```bash
|
||||
cat ~/.clawdis/clawdis.json
|
||||
```
|
||||
154
docs/faq.md
154
docs/faq.md
@ -66,6 +66,35 @@ pnpm clawdis doctor
|
||||
|
||||
It checks your config, skills status, and gateway health. It can also restart the gateway daemon if needed.
|
||||
|
||||
### Terminal onboarding vs macOS app?
|
||||
|
||||
**Use terminal onboarding** (`pnpm clawdis onboard`) — it's more stable right now.
|
||||
|
||||
The macOS app onboarding is still being polished and can have quirks (e.g., WhatsApp 515 errors, OAuth issues).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### OAuth vs API key — what's the difference?
|
||||
|
||||
- **OAuth** — Uses your Claude Pro/Max subscription ($20-100/mo flat). No per-token charges. ✅ Recommended!
|
||||
- **API key** — Pay-per-token via console.anthropic.com. Can get expensive fast.
|
||||
|
||||
They're **separate billing**! An API key does NOT use your subscription.
|
||||
|
||||
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back.
|
||||
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdis/` to your server. The auth is just a JSON file.
|
||||
|
||||
### OAuth callback not working (containers/headless)?
|
||||
|
||||
OAuth needs the callback to reach the machine running the CLI. Options:
|
||||
|
||||
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdis/credentials/` to the container.
|
||||
2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server`
|
||||
3. **Tailscale** — Put both machines on your tailnet.
|
||||
|
||||
---
|
||||
|
||||
## Migration & Deployment
|
||||
@ -108,16 +137,30 @@ There's no official Docker setup yet, but it works. Key considerations:
|
||||
|
||||
- **WhatsApp login:** QR code works in terminal — no display needed.
|
||||
- **Persistence:** Mount `~/.clawdis/` and your workspace as volumes.
|
||||
- **pnpm doesn't persist:** Global npm installs don't survive container restarts. Install pnpm in your startup script.
|
||||
- **Browser automation:** Optional. If needed, install headless Chrome + Playwright deps, or connect to a remote browser via `--remote-debugging-port`.
|
||||
|
||||
Basic approach:
|
||||
```dockerfile
|
||||
FROM node:22
|
||||
WORKDIR /app
|
||||
# Clone, pnpm install, pnpm build
|
||||
# Mount volumes for persistence
|
||||
CMD ["pnpm", "clawdis", "gateway"]
|
||||
**Volume mappings (e.g., Unraid):**
|
||||
```
|
||||
/mnt/user/appdata/clawdis/config → /root/.clawdis
|
||||
/mnt/user/appdata/clawdis/workspace → /root/clawd
|
||||
/mnt/user/appdata/clawdis/app → /app
|
||||
```
|
||||
|
||||
**Startup script (`start.sh`):**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
npm install -g pnpm
|
||||
cd /app
|
||||
pnpm clawdis gateway
|
||||
```
|
||||
|
||||
**Container command:**
|
||||
```
|
||||
bash /app/start.sh
|
||||
```
|
||||
|
||||
Docker support is on the roadmap — PRs welcome!
|
||||
|
||||
### Can I run Clawdis headless on a VPS?
|
||||
|
||||
@ -126,6 +169,31 @@ Yes! The terminal QR code login works fine over SSH. For long-running operation:
|
||||
- Use `pm2`, `systemd`, or a `launchd` plist to keep the gateway running.
|
||||
- Consider Tailscale for secure remote access.
|
||||
|
||||
### bun binary vs Node runtime?
|
||||
|
||||
Clawdis can run as:
|
||||
- **bun binary** — Single executable, easy distribution, auto-restarts via launchd
|
||||
- **Node runtime** (`pnpm clawdis gateway`) — More stable for WhatsApp
|
||||
|
||||
If you see WebSocket errors like `ws.WebSocket 'upgrade' event is not implemented`, use Node instead of the bun binary. Bun's WebSocket implementation has edge cases that can break WhatsApp (Baileys).
|
||||
|
||||
**For stability:** Use launchd (macOS) or the Clawdis.app — they handle process supervision (auto-restart on crash).
|
||||
|
||||
**For debugging:** Use `pnpm gateway:watch` for live reload during development.
|
||||
|
||||
### WhatsApp keeps disconnecting / crashing (macOS app)
|
||||
|
||||
This is often the bun WebSocket issue. Workaround:
|
||||
|
||||
1. Run gateway with Node instead:
|
||||
```bash
|
||||
pnpm gateway:watch
|
||||
```
|
||||
2. In **Clawdis.app → Settings → Debug**, check **"External gateway"**
|
||||
3. The app now connects to your Node gateway instead of spawning bun
|
||||
|
||||
This is the most stable setup until bun's WebSocket handling improves.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Instance & Contexts
|
||||
@ -213,6 +281,31 @@ One WhatsApp account = one phone number = one gateway connection. For a second n
|
||||
|
||||
---
|
||||
|
||||
## Skills & Tools
|
||||
|
||||
### How do I add new skills?
|
||||
|
||||
Skills are auto-discovered from your workspace's `skills/` folder. After adding new skills:
|
||||
|
||||
1. Send `/reset` (or `/new`) in chat to start a new session
|
||||
2. The new skills will be available
|
||||
|
||||
No gateway restart needed!
|
||||
|
||||
### How do I run commands on other machines?
|
||||
|
||||
Use **[Tailscale](https://tailscale.com/)** to create a secure network between your machines:
|
||||
|
||||
1. Install Tailscale on all machines (it's separate from Clawdis — set it up yourself)
|
||||
2. Each gets a stable IP (like `100.x.x.x`)
|
||||
3. SSH just works: `ssh user@100.x.x.x "command"`
|
||||
|
||||
Clawdis can use Tailscale when you set `bridge.bind: "tailnet"` in your config — it auto-detects your Tailscale IP.
|
||||
|
||||
For deeper integration, look into **Clawdis nodes** — pair remote machines with your gateway for camera/screen/automation access.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build errors (TypeScript)
|
||||
@ -246,6 +339,53 @@ Common issues:
|
||||
- Missing API keys in config
|
||||
- Invalid config syntax (remember it's JSON5, but still check for errors)
|
||||
|
||||
**Debug mode** — use watch for live reload:
|
||||
```bash
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
**Pro tip:** Use Codex to debug:
|
||||
```bash
|
||||
cd ~/path/to/clawdis
|
||||
codex --full-auto "debug why clawdis gateway won't start"
|
||||
```
|
||||
|
||||
### Processes keep restarting after I kill them (Linux)
|
||||
|
||||
Something is supervising them. Check:
|
||||
|
||||
```bash
|
||||
# systemd?
|
||||
systemctl list-units | grep -i clawdis
|
||||
sudo systemctl stop clawdis
|
||||
|
||||
# pm2?
|
||||
pm2 list
|
||||
pm2 delete all
|
||||
```
|
||||
|
||||
Stop the supervisor first, then the processes.
|
||||
|
||||
### Clean uninstall (start fresh)
|
||||
|
||||
```bash
|
||||
# Stop processes
|
||||
pkill -f "clawdis"
|
||||
|
||||
# If using systemd
|
||||
sudo systemctl stop clawdis
|
||||
sudo systemctl disable clawdis
|
||||
|
||||
# Remove data
|
||||
rm -rf ~/.clawdis
|
||||
|
||||
# Remove repo and re-clone
|
||||
rm -rf ~/clawdis
|
||||
git clone https://github.com/steipete/clawdis.git
|
||||
cd clawdis && pnpm install && pnpm build
|
||||
pnpm clawdis onboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chat Commands
|
||||
|
||||
@ -9,7 +9,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
|
||||
|
||||
## What’s implemented (2025-12-03)
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config.
|
||||
- Group allowlist bypass: we still enforce `routing.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||
- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||
- Per-group sessions: session keys look like `whatsapp:group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
||||
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
||||
@ -45,7 +45,7 @@ Use the group chat command:
|
||||
- `/activation mention`
|
||||
- `/activation always`
|
||||
|
||||
Only the owner number (from `routing.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
|
||||
Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
|
||||
|
||||
## How to use
|
||||
1) Add Clawd UK (`+447700900123`) to the group.
|
||||
|
||||
@ -40,7 +40,7 @@ Group owners can toggle per-group activation:
|
||||
- `/activation mention`
|
||||
- `/activation always`
|
||||
|
||||
Owner is determined by `routing.allowFrom` (or the bot’s default identity when unset).
|
||||
Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`.
|
||||
|
||||
## Context fields
|
||||
Group inbound payloads set:
|
||||
|
||||
@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
||||
## When something fails
|
||||
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
||||
- Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy).
|
||||
- No inbound messages → confirm linked phone is online and the sender is allowed (`routing.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`).
|
||||
- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`).
|
||||
|
||||
## Dedicated "health" command
|
||||
`clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default.
|
||||
|
||||
@ -100,16 +100,14 @@ clawdis send --to +15555550123 --message "Hello from CLAWDIS"
|
||||
Config lives at `~/.clawdis/clawdis.json`.
|
||||
|
||||
- If you **do nothing**, CLAWDIS uses the bundled Pi binary in RPC mode with per-sender sessions.
|
||||
- If you want to lock it down, start with `routing.allowFrom` and (for groups) mention rules.
|
||||
- If you want to lock it down, start with `whatsapp.allowFrom` and (for groups) mention rules.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
allowFrom: ["+15555550123"],
|
||||
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }
|
||||
}
|
||||
whatsapp: { allowFrom: ["+15555550123"] },
|
||||
routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -22,6 +22,10 @@ First question: where does the **Gateway** run?
|
||||
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally.
|
||||
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
|
||||
|
||||
Gateway auth tip:
|
||||
- If you only use Clawdis on this Mac (loopback gateway), keep auth **Off**.
|
||||
- Use **Token** for multi-machine access or non-loopback binds.
|
||||
|
||||
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
|
||||
|
||||
## 2) Local-only: Connect Claude (Anthropic OAuth)
|
||||
|
||||
@ -42,7 +42,7 @@ This is social engineering 101. Create distrust, encourage snooping.
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"whatsapp": {
|
||||
"allowFrom": ["+15555550123"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
||||
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
|
||||
4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
|
||||
5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:<chatId>` and require mention/command to trigger replies.
|
||||
6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
||||
6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
||||
|
||||
## Capabilities & limits (Bot API)
|
||||
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
||||
|
||||
@ -22,9 +22,9 @@ The agent was interrupted mid-response.
|
||||
|
||||
### Messages Not Triggering
|
||||
|
||||
**Check 1:** Is the sender in `routing.allowFrom`?
|
||||
**Check 1:** Is the sender in `whatsapp.allowFrom`?
|
||||
```bash
|
||||
cat ~/.clawdis/clawdis.json | jq '.routing.allowFrom'
|
||||
cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom'
|
||||
```
|
||||
|
||||
**Check 2:** For group chats, is mention required?
|
||||
|
||||
@ -31,8 +31,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
||||
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
|
||||
- Status/broadcast chats are ignored.
|
||||
- Direct chats use E.164; groups use group JID.
|
||||
- **Allowlist**: `routing.allowFrom` enforced for direct chats only.
|
||||
- If `routing.allowFrom` is empty, default allowlist = self number (self-chat mode).
|
||||
- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only.
|
||||
- If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode).
|
||||
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
||||
- Read receipts sent for non-self-chat DMs.
|
||||
|
||||
@ -57,7 +57,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
||||
- `mention` (default): requires @mention or regex match.
|
||||
- `always`: always triggers.
|
||||
- `/activation mention|always` is owner-only.
|
||||
- Owner = `routing.allowFrom` (or self E.164 if unset).
|
||||
- Owner = `whatsapp.allowFrom` (or self E.164 if unset).
|
||||
- **History injection**:
|
||||
- Recent messages (default 50) inserted under:
|
||||
`[Chat messages since your last reply - for context]`
|
||||
@ -98,7 +98,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
||||
- Logged-out => stop and require re-link.
|
||||
|
||||
## Config quick map
|
||||
- `routing.allowFrom` (DM allowlist).
|
||||
- `whatsapp.allowFrom` (DM allowlist).
|
||||
- `routing.groupChat.mentionPatterns`
|
||||
- `routing.groupChat.historyLimit`
|
||||
- `messages.messagePrefix` (inbound prefix)
|
||||
|
||||
@ -58,6 +58,7 @@ It does **not** install or change anything on the remote host.
|
||||
|
||||
4) **Gateway**
|
||||
- Port, bind, auth mode, tailscale exposure.
|
||||
- Auth recommendation: keep **Off** for single-machine loopback setups. Use **Token** for multi-machine access or non-loopback binds.
|
||||
- Non‑loopback binds require auth.
|
||||
|
||||
5) **Providers**
|
||||
|
||||
@ -98,6 +98,8 @@ cat > "$ENT_TMP_BASE" <<'PLIST'
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
@ -111,6 +113,8 @@ cat > "$ENT_TMP_APP_BASE" <<'PLIST'
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
@ -139,6 +143,8 @@ cat > "$ENT_TMP_APP" <<'PLIST'
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
101
skills/local-places/SERVER_README.md
Normal file
101
skills/local-places/SERVER_README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Local Places
|
||||
|
||||
This repo is a fusion of two pieces:
|
||||
|
||||
- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API.
|
||||
- A companion agent skill that explains how to use the API and can call it to find places efficiently.
|
||||
|
||||
Together, the skill and server let an agent turn natural-language place queries into structured results quickly.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# copy skill definition into the relevant folder (where the agent looks for it)
|
||||
# then run the server
|
||||
|
||||
uv venv
|
||||
uv pip install -e ".[dev]"
|
||||
uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload
|
||||
```
|
||||
|
||||
Open the API docs at http://127.0.0.1:8000/docs.
|
||||
|
||||
## Places API
|
||||
|
||||
Set the Google Places API key before running:
|
||||
|
||||
```bash
|
||||
export GOOGLE_PLACES_API_KEY="your-key"
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `POST /places/search` (free-text query + filters)
|
||||
- `GET /places/{place_id}` (place details)
|
||||
- `POST /locations/resolve` (resolve a user-provided location string)
|
||||
|
||||
Example search request:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "italian restaurant",
|
||||
"filters": {
|
||||
"types": ["restaurant"],
|
||||
"open_now": true,
|
||||
"min_rating": 4.0,
|
||||
"price_levels": [1, 2]
|
||||
},
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `filters.types` supports a single type (mapped to Google `includedType`).
|
||||
|
||||
Example search request (curl):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/places/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "italian restaurant",
|
||||
"location_bias": {
|
||||
"lat": 40.8065,
|
||||
"lng": -73.9719,
|
||||
"radius_m": 3000
|
||||
},
|
||||
"filters": {
|
||||
"types": ["restaurant"],
|
||||
"open_now": true,
|
||||
"min_rating": 4.0,
|
||||
"price_levels": [1, 2, 3]
|
||||
},
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
Example resolve request (curl):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/locations/resolve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"location_text": "Riverside Park, New York",
|
||||
"limit": 5
|
||||
}'
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Generate the OpenAPI schema:
|
||||
|
||||
```bash
|
||||
uv run python scripts/generate_openapi.py
|
||||
```
|
||||
91
skills/local-places/SKILL.md
Normal file
91
skills/local-places/SKILL.md
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
name: local-places
|
||||
description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
|
||||
homepage: https://github.com/Hyaxia/local_places
|
||||
metadata: {"clawdis":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
|
||||
---
|
||||
|
||||
# 📍 Local Places
|
||||
|
||||
*Find places, Go fast*
|
||||
|
||||
Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd {baseDir}
|
||||
echo "GOOGLE_PLACES_API_KEY=your-key" > .env
|
||||
uv venv && uv pip install -e ".[dev]"
|
||||
uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Check server:** `curl http://127.0.0.1:8000/ping`
|
||||
|
||||
2. **Resolve location:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/locations/resolve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"location_text": "Soho, London", "limit": 5}'
|
||||
```
|
||||
|
||||
3. **Search places:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/places/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "coffee shop",
|
||||
"location_bias": {"lat": 51.5137, "lng": -0.1366, "radius_m": 1000},
|
||||
"filters": {"open_now": true, "min_rating": 4.0},
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
4. **Get details:**
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/places/{place_id}
|
||||
```
|
||||
|
||||
## Conversation Flow
|
||||
|
||||
1. If user says "near me" or gives vague location → resolve it first
|
||||
2. If multiple results → show numbered list, ask user to pick
|
||||
3. Ask for preferences: type, open now, rating, price level
|
||||
4. Search with `location_bias` from chosen location
|
||||
5. Present results with name, rating, address, open status
|
||||
6. Offer to fetch details or refine search
|
||||
|
||||
## Filter Constraints
|
||||
|
||||
- `filters.types`: exactly ONE type (e.g., "restaurant", "cafe", "gym")
|
||||
- `filters.price_levels`: integers 0-4 (0=free, 4=very expensive)
|
||||
- `filters.min_rating`: 0-5 in 0.5 increments
|
||||
- `filters.open_now`: boolean
|
||||
- `limit`: 1-20 for search, 1-10 for resolve
|
||||
- `location_bias.radius_m`: must be > 0
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"place_id": "ChIJ...",
|
||||
"name": "Coffee Shop",
|
||||
"address": "123 Main St",
|
||||
"location": {"lat": 51.5, "lng": -0.1},
|
||||
"rating": 4.6,
|
||||
"price_level": 2,
|
||||
"types": ["cafe", "food"],
|
||||
"open_now": true
|
||||
}
|
||||
],
|
||||
"next_page_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Use `next_page_token` as `page_token` in next request for more results.
|
||||
27
skills/local-places/pyproject.toml
Normal file
27
skills/local-places/pyproject.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "my-api"
|
||||
version = "0.1.0"
|
||||
description = "FastAPI server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.110.0",
|
||||
"httpx>=0.27.0",
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/local_places"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
testpaths = ["tests"]
|
||||
2
skills/local-places/src/local_places/__init__.py
Normal file
2
skills/local-places/src/local_places/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
314
skills/local-places/src/local_places/google_places.py
Normal file
314
skills/local-places/src/local_places/google_places.py
Normal file
@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from local_places.schemas import (
|
||||
LatLng,
|
||||
LocationResolveRequest,
|
||||
LocationResolveResponse,
|
||||
PlaceDetails,
|
||||
PlaceSummary,
|
||||
ResolvedLocation,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
)
|
||||
|
||||
GOOGLE_PLACES_BASE_URL = os.getenv(
|
||||
"GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1"
|
||||
)
|
||||
logger = logging.getLogger("local_places.google_places")
|
||||
|
||||
_PRICE_LEVEL_TO_ENUM = {
|
||||
0: "PRICE_LEVEL_FREE",
|
||||
1: "PRICE_LEVEL_INEXPENSIVE",
|
||||
2: "PRICE_LEVEL_MODERATE",
|
||||
3: "PRICE_LEVEL_EXPENSIVE",
|
||||
4: "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||
}
|
||||
_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()}
|
||||
|
||||
_SEARCH_FIELD_MASK = (
|
||||
"places.id,"
|
||||
"places.displayName,"
|
||||
"places.formattedAddress,"
|
||||
"places.location,"
|
||||
"places.rating,"
|
||||
"places.priceLevel,"
|
||||
"places.types,"
|
||||
"places.currentOpeningHours,"
|
||||
"nextPageToken"
|
||||
)
|
||||
|
||||
_DETAILS_FIELD_MASK = (
|
||||
"id,"
|
||||
"displayName,"
|
||||
"formattedAddress,"
|
||||
"location,"
|
||||
"rating,"
|
||||
"priceLevel,"
|
||||
"types,"
|
||||
"regularOpeningHours,"
|
||||
"currentOpeningHours,"
|
||||
"nationalPhoneNumber,"
|
||||
"websiteUri"
|
||||
)
|
||||
|
||||
_RESOLVE_FIELD_MASK = (
|
||||
"places.id,"
|
||||
"places.displayName,"
|
||||
"places.formattedAddress,"
|
||||
"places.location,"
|
||||
"places.types"
|
||||
)
|
||||
|
||||
|
||||
class _GoogleResponse:
|
||||
def __init__(self, response: httpx.Response):
|
||||
self.status_code = response.status_code
|
||||
self._response = response
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._response.json()
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._response.text
|
||||
|
||||
|
||||
def _api_headers(field_mask: str) -> dict[str, str]:
|
||||
api_key = os.getenv("GOOGLE_PLACES_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="GOOGLE_PLACES_API_KEY is not set.",
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": api_key,
|
||||
"X-Goog-FieldMask": field_mask,
|
||||
}
|
||||
|
||||
|
||||
def _request(
|
||||
method: str, url: str, payload: dict[str, Any] | None, field_mask: str
|
||||
) -> _GoogleResponse:
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=_api_headers(field_mask),
|
||||
json=payload,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc
|
||||
|
||||
return _GoogleResponse(response)
|
||||
|
||||
|
||||
def _build_text_query(request: SearchRequest) -> str:
|
||||
keyword = request.filters.keyword if request.filters else None
|
||||
if keyword:
|
||||
return f"{request.query} {keyword}".strip()
|
||||
return request.query
|
||||
|
||||
|
||||
def _build_search_body(request: SearchRequest) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"textQuery": _build_text_query(request),
|
||||
"pageSize": request.limit,
|
||||
}
|
||||
|
||||
if request.page_token:
|
||||
body["pageToken"] = request.page_token
|
||||
|
||||
if request.location_bias:
|
||||
body["locationBias"] = {
|
||||
"circle": {
|
||||
"center": {
|
||||
"latitude": request.location_bias.lat,
|
||||
"longitude": request.location_bias.lng,
|
||||
},
|
||||
"radius": request.location_bias.radius_m,
|
||||
}
|
||||
}
|
||||
|
||||
if request.filters:
|
||||
filters = request.filters
|
||||
if filters.types:
|
||||
body["includedType"] = filters.types[0]
|
||||
if filters.open_now is not None:
|
||||
body["openNow"] = filters.open_now
|
||||
if filters.min_rating is not None:
|
||||
body["minRating"] = filters.min_rating
|
||||
if filters.price_levels:
|
||||
body["priceLevels"] = [
|
||||
_PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels
|
||||
]
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None:
|
||||
if not raw:
|
||||
return None
|
||||
latitude = raw.get("latitude")
|
||||
longitude = raw.get("longitude")
|
||||
if latitude is None or longitude is None:
|
||||
return None
|
||||
return LatLng(lat=latitude, lng=longitude)
|
||||
|
||||
|
||||
def _parse_display_name(raw: dict[str, Any] | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
return raw.get("text")
|
||||
|
||||
|
||||
def _parse_open_now(raw: dict[str, Any] | None) -> bool | None:
|
||||
if not raw:
|
||||
return None
|
||||
return raw.get("openNow")
|
||||
|
||||
|
||||
def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None:
|
||||
if not raw:
|
||||
return None
|
||||
return raw.get("weekdayDescriptions")
|
||||
|
||||
|
||||
def _parse_price_level(raw: str | None) -> int | None:
|
||||
if not raw:
|
||||
return None
|
||||
return _ENUM_TO_PRICE_LEVEL.get(raw)
|
||||
|
||||
|
||||
def search_places(request: SearchRequest) -> SearchResponse:
|
||||
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
||||
response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Google Places API error %s. response=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Google Places API error ({response.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Google Places API returned invalid JSON. response=%s",
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||
|
||||
places = payload.get("places", [])
|
||||
results = []
|
||||
for place in places:
|
||||
results.append(
|
||||
PlaceSummary(
|
||||
place_id=place.get("id", ""),
|
||||
name=_parse_display_name(place.get("displayName")),
|
||||
address=place.get("formattedAddress"),
|
||||
location=_parse_lat_lng(place.get("location")),
|
||||
rating=place.get("rating"),
|
||||
price_level=_parse_price_level(place.get("priceLevel")),
|
||||
types=place.get("types"),
|
||||
open_now=_parse_open_now(place.get("currentOpeningHours")),
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
results=results,
|
||||
next_page_token=payload.get("nextPageToken"),
|
||||
)
|
||||
|
||||
|
||||
def get_place_details(place_id: str) -> PlaceDetails:
|
||||
url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}"
|
||||
response = _request("GET", url, None, _DETAILS_FIELD_MASK)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Google Places API error %s. response=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Google Places API error ({response.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Google Places API returned invalid JSON. response=%s",
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||
|
||||
return PlaceDetails(
|
||||
place_id=payload.get("id", place_id),
|
||||
name=_parse_display_name(payload.get("displayName")),
|
||||
address=payload.get("formattedAddress"),
|
||||
location=_parse_lat_lng(payload.get("location")),
|
||||
rating=payload.get("rating"),
|
||||
price_level=_parse_price_level(payload.get("priceLevel")),
|
||||
types=payload.get("types"),
|
||||
phone=payload.get("nationalPhoneNumber"),
|
||||
website=payload.get("websiteUri"),
|
||||
hours=_parse_hours(payload.get("regularOpeningHours")),
|
||||
open_now=_parse_open_now(payload.get("currentOpeningHours")),
|
||||
)
|
||||
|
||||
|
||||
def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse:
|
||||
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
||||
body = {"textQuery": request.location_text, "pageSize": request.limit}
|
||||
response = _request("POST", url, body, _RESOLVE_FIELD_MASK)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Google Places API error %s. response=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Google Places API error ({response.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Google Places API returned invalid JSON. response=%s",
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||
|
||||
places = payload.get("places", [])
|
||||
results = []
|
||||
for place in places:
|
||||
results.append(
|
||||
ResolvedLocation(
|
||||
place_id=place.get("id", ""),
|
||||
name=_parse_display_name(place.get("displayName")),
|
||||
address=place.get("formattedAddress"),
|
||||
location=_parse_lat_lng(place.get("location")),
|
||||
types=place.get("types"),
|
||||
)
|
||||
)
|
||||
|
||||
return LocationResolveResponse(results=results)
|
||||
65
skills/local-places/src/local_places/main.py
Normal file
65
skills/local-places/src/local_places/main.py
Normal file
@ -0,0 +1,65 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from local_places.google_places import get_place_details, resolve_locations, search_places
|
||||
from local_places.schemas import (
|
||||
LocationResolveRequest,
|
||||
LocationResolveResponse,
|
||||
PlaceDetails,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="My API",
|
||||
servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}],
|
||||
)
|
||||
logger = logging.getLogger("local_places.validation")
|
||||
|
||||
|
||||
@app.get("/ping")
|
||||
def ping() -> dict[str, str]:
|
||||
return {"message": "pong"}
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
logger.error(
|
||||
"Validation error on %s %s. body=%s errors=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
exc.body,
|
||||
exc.errors(),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=jsonable_encoder({"detail": exc.errors()}),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/places/search", response_model=SearchResponse)
|
||||
def places_search(request: SearchRequest) -> SearchResponse:
|
||||
return search_places(request)
|
||||
|
||||
|
||||
@app.get("/places/{place_id}", response_model=PlaceDetails)
|
||||
def places_details(place_id: str) -> PlaceDetails:
|
||||
return get_place_details(place_id)
|
||||
|
||||
|
||||
@app.post("/locations/resolve", response_model=LocationResolveResponse)
|
||||
def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse:
|
||||
return resolve_locations(request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000)
|
||||
107
skills/local-places/src/local_places/schemas.py
Normal file
107
skills/local-places/src/local_places/schemas.py
Normal file
@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LatLng(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lng: float = Field(ge=-180, le=180)
|
||||
|
||||
|
||||
class LocationBias(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lng: float = Field(ge=-180, le=180)
|
||||
radius_m: float = Field(gt=0)
|
||||
|
||||
|
||||
class Filters(BaseModel):
|
||||
types: list[str] | None = None
|
||||
open_now: bool | None = None
|
||||
min_rating: float | None = Field(default=None, ge=0, le=5)
|
||||
price_levels: list[int] | None = None
|
||||
keyword: str | None = Field(default=None, min_length=1)
|
||||
|
||||
@field_validator("types")
|
||||
@classmethod
|
||||
def validate_types(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return value
|
||||
if len(value) > 1:
|
||||
raise ValueError(
|
||||
"Only one type is supported. Use query/keyword for additional filtering."
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("price_levels")
|
||||
@classmethod
|
||||
def validate_price_levels(cls, value: list[int] | None) -> list[int] | None:
|
||||
if value is None:
|
||||
return value
|
||||
invalid = [level for level in value if level not in range(0, 5)]
|
||||
if invalid:
|
||||
raise ValueError("price_levels must be integers between 0 and 4.")
|
||||
return value
|
||||
|
||||
@field_validator("min_rating")
|
||||
@classmethod
|
||||
def validate_min_rating(cls, value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return value
|
||||
if (value * 2) % 1 != 0:
|
||||
raise ValueError("min_rating must be in 0.5 increments.")
|
||||
return value
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str = Field(min_length=1)
|
||||
location_bias: LocationBias | None = None
|
||||
filters: Filters | None = None
|
||||
limit: int = Field(default=10, ge=1, le=20)
|
||||
page_token: str | None = None
|
||||
|
||||
|
||||
class PlaceSummary(BaseModel):
|
||||
place_id: str
|
||||
name: str | None = None
|
||||
address: str | None = None
|
||||
location: LatLng | None = None
|
||||
rating: float | None = None
|
||||
price_level: int | None = None
|
||||
types: list[str] | None = None
|
||||
open_now: bool | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[PlaceSummary]
|
||||
next_page_token: str | None = None
|
||||
|
||||
|
||||
class LocationResolveRequest(BaseModel):
|
||||
location_text: str = Field(min_length=1)
|
||||
limit: int = Field(default=5, ge=1, le=10)
|
||||
|
||||
|
||||
class ResolvedLocation(BaseModel):
|
||||
place_id: str
|
||||
name: str | None = None
|
||||
address: str | None = None
|
||||
location: LatLng | None = None
|
||||
types: list[str] | None = None
|
||||
|
||||
|
||||
class LocationResolveResponse(BaseModel):
|
||||
results: list[ResolvedLocation]
|
||||
|
||||
|
||||
class PlaceDetails(BaseModel):
|
||||
place_id: str
|
||||
name: str | None = None
|
||||
address: str | None = None
|
||||
location: LatLng | None = None
|
||||
rating: float | None = None
|
||||
price_level: int | None = None
|
||||
types: list[str] | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
hours: list[str] | None = None
|
||||
open_now: bool | None = None
|
||||
29
skills/songsee/SKILL.md
Normal file
29
skills/songsee/SKILL.md
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
name: songsee
|
||||
description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI.
|
||||
homepage: https://github.com/steipete/songsee
|
||||
metadata: {"clawdis":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}}
|
||||
---
|
||||
|
||||
# songsee
|
||||
|
||||
Generate spectrograms + feature panels from audio.
|
||||
|
||||
Quick start
|
||||
- Spectrogram: `songsee track.mp3`
|
||||
- Multi-panel: `songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux`
|
||||
- Time slice: `songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg`
|
||||
- Stdin: `cat track.mp3 | songsee - --format png -o out.png`
|
||||
|
||||
Common flags
|
||||
- `--viz` list (repeatable or comma-separated)
|
||||
- `--style` palette (classic, magma, inferno, viridis, gray)
|
||||
- `--width` / `--height` output size
|
||||
- `--window` / `--hop` FFT settings
|
||||
- `--min-freq` / `--max-freq` frequency range
|
||||
- `--start` / `--duration` time slice
|
||||
- `--format` jpg|png
|
||||
|
||||
Notes
|
||||
- WAV/MP3 decode native; other formats use ffmpeg if available.
|
||||
- Multiple `--viz` renders a grid.
|
||||
49
skills/weather/SKILL.md
Normal file
49
skills/weather/SKILL.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
name: weather
|
||||
description: Get current weather and forecasts (no API key required).
|
||||
homepage: https://wttr.in/:help
|
||||
metadata: {"clawdis":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
|
||||
---
|
||||
|
||||
# Weather
|
||||
|
||||
Two free services, no API keys needed.
|
||||
|
||||
## wttr.in (primary)
|
||||
|
||||
Quick one-liner:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=3"
|
||||
# Output: London: ⛅️ +8°C
|
||||
```
|
||||
|
||||
Compact format:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
||||
```
|
||||
|
||||
Full forecast:
|
||||
```bash
|
||||
curl -s "wttr.in/London?T"
|
||||
```
|
||||
|
||||
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
|
||||
|
||||
Tips:
|
||||
- URL-encode spaces: `wttr.in/New+York`
|
||||
- Airport codes: `wttr.in/JFK`
|
||||
- Units: `?m` (metric) `?u` (USCS)
|
||||
- Today only: `?1` · Current only: `?0`
|
||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
||||
|
||||
## Open-Meteo (fallback, JSON)
|
||||
|
||||
Free, no key, good for programmatic use:
|
||||
```bash
|
||||
curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true"
|
||||
```
|
||||
|
||||
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
|
||||
|
||||
Docs: https://open-meteo.com/en/docs
|
||||
53
src/agents/clawdis-tools.camera.test.ts
Normal file
53
src/agents/clawdis-tools.camera.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn();
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({ callGateway }));
|
||||
vi.mock("../media/image-ops.js", () => ({
|
||||
getImageMetadata: vi.fn(async () => ({ width: 1, height: 1 })),
|
||||
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
|
||||
}));
|
||||
|
||||
import { createClawdisTools } from "./clawdis-tools.js";
|
||||
|
||||
describe("clawdis_nodes camera_snap", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
});
|
||||
|
||||
it("maps jpg payloads to image/jpeg", async () => {
|
||||
callGateway.mockImplementation(async ({ method }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1" }] };
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
return {
|
||||
payload: {
|
||||
format: "jpg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
|
||||
const tool = createClawdisTools().find(
|
||||
(candidate) => candidate.name === "clawdis_nodes",
|
||||
);
|
||||
if (!tool) throw new Error("missing clawdis_nodes tool");
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
action: "camera_snap",
|
||||
node: "mac-1",
|
||||
facing: "front",
|
||||
});
|
||||
|
||||
const images = (result.content ?? []).filter(
|
||||
(block) => block.type === "image",
|
||||
);
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0]?.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
@ -43,7 +43,7 @@ import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { reactMessageDiscord } from "../discord/send.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { detectMime, imageMimeFromFormat } from "../media/mime.js";
|
||||
import { sanitizeToolResultImages } from "./tool-images.js";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-ai uses a different module instance.
|
||||
@ -875,7 +875,7 @@ function createCanvasTool(): AnyAgentTool {
|
||||
});
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
const mimeType =
|
||||
payload.format === "jpeg" ? "image/jpeg" : "image/png";
|
||||
imageMimeFromFormat(payload.format) ?? "image/png";
|
||||
return await imageResult({
|
||||
label: "canvas:snapshot",
|
||||
path: filePath,
|
||||
@ -1141,7 +1141,8 @@ function createNodesTool(): AnyAgentTool {
|
||||
content.push({
|
||||
type: "image",
|
||||
data: payload.base64,
|
||||
mimeType: payload.format === "jpeg" ? "image/jpeg" : "image/png",
|
||||
mimeType:
|
||||
imageMimeFromFormat(payload.format) ?? "image/png",
|
||||
});
|
||||
details.push({
|
||||
facing,
|
||||
|
||||
@ -10,7 +10,7 @@ describe("buildAgentSystemPromptAppend", () => {
|
||||
|
||||
expect(prompt).toContain("## User Identity");
|
||||
expect(prompt).toContain(
|
||||
"Owner numbers: +123, +456. Treat messages from these numbers as the user (Peter).",
|
||||
"Owner numbers: +123, +456. Treat messages from these numbers as the user.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
.filter(Boolean);
|
||||
const ownerLine =
|
||||
ownerNumbers.length > 0
|
||||
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user (Peter).`
|
||||
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
|
||||
: undefined;
|
||||
const reasoningHint = params.reasoningTagHint
|
||||
? [
|
||||
@ -36,7 +36,7 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
|
||||
"Example:",
|
||||
"<think>Short internal reasoning.</think>",
|
||||
"<final>Hey Peter! What would you like to do next?</final>",
|
||||
"<final>Hey there! What would you like to do next?</final>",
|
||||
].join(" ")
|
||||
: undefined;
|
||||
const runtimeInfo = params.runtimeInfo;
|
||||
|
||||
@ -118,7 +118,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@ -168,7 +168,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@ -195,7 +195,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@ -208,7 +208,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@ -264,7 +264,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
@ -325,7 +325,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
@ -506,7 +506,7 @@ describe("directive parsing", () => {
|
||||
workspace: path.join(home, "clawd"),
|
||||
allowedModels: ["openai/gpt-4.1-mini"],
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
|
||||
@ -42,7 +42,7 @@ function makeCfg(home: string) {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
@ -283,8 +283,10 @@ describe("trigger handling", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
routing: {
|
||||
groupChat: { requireMention: false },
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
@ -324,7 +326,7 @@ describe("trigger handling", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: {
|
||||
@ -363,7 +365,7 @@ describe("trigger handling", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: {
|
||||
|
||||
@ -841,14 +841,27 @@ export async function getReplyFromConfig(
|
||||
const perMessageQueueMode =
|
||||
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
||||
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
|
||||
// WhatsApp owner allowlist (E.164 without whatsapp: prefix); used for group activation only.
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const isSamePhone = from && to && from === to;
|
||||
// If no config is present, default to self-only DM access.
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
if (isWhatsAppSurface && isEmptyConfig && from && to && from !== to) {
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
const defaultAllowFrom =
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
||||
isWhatsAppSurface &&
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) &&
|
||||
to
|
||||
? [to]
|
||||
: undefined;
|
||||
const allowFrom =
|
||||
@ -862,10 +875,12 @@ export async function getReplyFromConfig(
|
||||
: rawBodyNormalized;
|
||||
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates = (allowFrom ?? []).filter(
|
||||
(entry) => entry && entry !== "*",
|
||||
);
|
||||
if (ownerCandidates.length === 0 && to) ownerCandidates.push(to);
|
||||
const ownerCandidates = isWhatsAppSurface
|
||||
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
@ -876,20 +891,6 @@ export async function getReplyFromConfig(
|
||||
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
||||
}
|
||||
|
||||
// Same-phone mode (self-messaging) is always allowed
|
||||
if (isSamePhone) {
|
||||
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
||||
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
// Support "*" as wildcard to allow all senders
|
||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||
);
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (activationCommand.hasCommand) {
|
||||
if (!isGroup) {
|
||||
cleanupTyping();
|
||||
|
||||
@ -2,6 +2,7 @@ export type MsgContext = {
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
SessionKey?: string;
|
||||
MessageSid?: string;
|
||||
ReplyToId?: string;
|
||||
ReplyToBody?: string;
|
||||
|
||||
@ -1 +1 @@
|
||||
969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335
|
||||
988ec7bedb11cab74f82faf4475df758e6f07866b69949ffc2cce89cb3d8265b
|
||||
|
||||
@ -10,6 +10,7 @@ import { sessionsCommand } from "../commands/sessions.js";
|
||||
import { setupCommand } from "../commands/setup.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { updateCommand } from "../commands/update.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { danger, setVerbose } from "../globals.js";
|
||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@ -68,6 +69,21 @@ export function buildProgram() {
|
||||
}
|
||||
|
||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
if (actionCommand.name() === "doctor") return;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.legacyIssues.length === 0) return;
|
||||
const issues = snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n");
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Legacy config entries detected. Run "clawdis doctor" (or ask your agent) to migrate.\n${issues}`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
const examples = [
|
||||
[
|
||||
"clawdis login --verbose",
|
||||
|
||||
@ -158,7 +158,7 @@ export async function agentCommand(
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
|
||||
const allowFrom = (cfg.routing?.allowFrom ?? [])
|
||||
const allowFrom = (cfg.whatsapp?.allowFrom ?? [])
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
|
||||
@ -451,7 +451,7 @@ export async function agentCommand(
|
||||
if (deliver) {
|
||||
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to WhatsApp requires --to <E.164> or routing.allowFrom[0]",
|
||||
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
|
||||
101
src/commands/doctor.test.ts
Normal file
101
src/commands/doctor.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readConfigFileSnapshot = vi.fn();
|
||||
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
||||
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
|
||||
config: raw as Record<string, unknown>,
|
||||
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm: vi.fn().mockResolvedValue(true),
|
||||
intro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/skills-status.js", () => ({
|
||||
buildWorkspaceSkillStatus: () => ({ skills: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
migrateLegacyConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: () => {
|
||||
throw new Error("exit");
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../utils.js", () => ({
|
||||
resolveUserPath: (value: string) => value,
|
||||
sleep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./health.js", () => ({
|
||||
healthCommand: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
applyWizardMetadata: (cfg: Record<string, unknown>) => cfg,
|
||||
DEFAULT_WORKSPACE: "/tmp",
|
||||
guardCancel: (value: unknown) => value,
|
||||
printWizardHeader: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("doctor", () => {
|
||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
path: "/tmp/clawdis.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: { routing: { allowFrom: ["+15555550123"] } },
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
],
|
||||
legacyIssues: [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { doctorCommand } = await import("./doctor.js");
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
migrateLegacyConfig.mockReturnValue({
|
||||
config: { whatsapp: { allowFrom: ["+15555550123"] } },
|
||||
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||
});
|
||||
|
||||
await doctorCommand(runtime);
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect((written.whatsapp as Record<string, unknown>)?.allowFrom).toEqual([
|
||||
"+15555550123",
|
||||
]);
|
||||
expect(written.routing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
@ -29,10 +30,42 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
if (
|
||||
snapshot.exists &&
|
||||
!snapshot.valid &&
|
||||
snapshot.legacyIssues.length === 0
|
||||
) {
|
||||
note("Config invalid; doctor will run with defaults.", "Config");
|
||||
}
|
||||
|
||||
if (snapshot.legacyIssues.length > 0) {
|
||||
note(
|
||||
snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n"),
|
||||
"Legacy config keys detected",
|
||||
);
|
||||
const migrate = guardCancel(
|
||||
await confirm({
|
||||
message: "Migrate legacy config entries now?",
|
||||
initialValue: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (migrate) {
|
||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
||||
const { config: migrated, changes } = migrateLegacyConfig(
|
||||
snapshot.parsed,
|
||||
);
|
||||
if (changes.length > 0) {
|
||||
note(changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (migrated) {
|
||||
cfg = migrated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDir = resolveUserPath(
|
||||
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
|
||||
);
|
||||
@ -57,7 +90,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
healthOk = true;
|
||||
} catch (err) {
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
} else {
|
||||
runtime.error(`Health check failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!healthOk) {
|
||||
@ -79,7 +117,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
} else {
|
||||
runtime.error(`Health check failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -280,8 +280,16 @@ export async function runInteractiveOnboarding(
|
||||
await select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{ value: "off", label: "Off (loopback only)" },
|
||||
{ value: "token", label: "Token" },
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Recommended for single-machine setups",
|
||||
},
|
||||
{
|
||||
value: "token",
|
||||
label: "Token",
|
||||
hint: "Use for multi-machine access or non-loopback binds",
|
||||
},
|
||||
{ value: "password", label: "Password" },
|
||||
],
|
||||
}),
|
||||
@ -344,6 +352,7 @@ export async function runInteractiveOnboarding(
|
||||
const tokenInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway token (blank to generate)",
|
||||
placeholder: "Needed for multi-machine or non-loopback access",
|
||||
initialValue: randomToken(),
|
||||
}),
|
||||
runtime,
|
||||
@ -375,7 +384,11 @@ export async function runInteractiveOnboarding(
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
auth: { ...nextConfig.gateway?.auth, mode: "token" },
|
||||
auth: {
|
||||
...nextConfig.gateway?.auth,
|
||||
mode: "token",
|
||||
token: gatewayToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -482,9 +495,18 @@ export async function runInteractiveOnboarding(
|
||||
note(
|
||||
(() => {
|
||||
const links = resolveControlUiLinks({ bind, port });
|
||||
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
|
||||
"\n",
|
||||
);
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||
return [
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
})(),
|
||||
"Control UI",
|
||||
);
|
||||
@ -498,7 +520,11 @@ export async function runInteractiveOnboarding(
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const links = resolveControlUiLinks({ bind, port });
|
||||
await openUrl(links.httpUrl);
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
await openUrl(`${links.httpUrl}${tokenParam}`);
|
||||
}
|
||||
|
||||
outro("Onboarding complete.");
|
||||
|
||||
@ -64,11 +64,11 @@ function noteDiscordTokenHelp(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
|
||||
function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
|
||||
return {
|
||||
...cfg,
|
||||
routing: {
|
||||
...cfg.routing,
|
||||
whatsapp: {
|
||||
...cfg.whatsapp,
|
||||
allowFrom,
|
||||
},
|
||||
};
|
||||
@ -78,13 +78,13 @@ async function promptWhatsAppAllowFrom(
|
||||
cfg: ClawdisConfig,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<ClawdisConfig> {
|
||||
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
|
||||
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||
const existingLabel =
|
||||
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||
|
||||
note(
|
||||
[
|
||||
"WhatsApp direct chats are gated by `routing.allowFrom`.",
|
||||
"WhatsApp direct chats are gated by `whatsapp.allowFrom`.",
|
||||
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
||||
`Current: ${existingLabel}`,
|
||||
].join("\n"),
|
||||
@ -114,8 +114,8 @@ async function promptWhatsAppAllowFrom(
|
||||
) as (typeof options)[number]["value"];
|
||||
|
||||
if (mode === "keep") return cfg;
|
||||
if (mode === "self") return setRoutingAllowFrom(cfg, undefined);
|
||||
if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]);
|
||||
if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
|
||||
if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
|
||||
|
||||
const allowRaw = guardCancel(
|
||||
await text({
|
||||
@ -148,7 +148,7 @@ async function promptWhatsAppAllowFrom(
|
||||
part === "*" ? "*" : normalizeE164(part),
|
||||
);
|
||||
const unique = [...new Set(normalized.filter(Boolean))];
|
||||
return setRoutingAllowFrom(cfg, unique);
|
||||
return setWhatsAppAllowFrom(cfg, unique);
|
||||
}
|
||||
|
||||
export async function setupProviders(
|
||||
|
||||
@ -22,6 +22,21 @@ function summarizeInstallFailure(message: string): string | undefined {
|
||||
return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned;
|
||||
}
|
||||
|
||||
function formatSkillHint(skill: {
|
||||
description?: string;
|
||||
install: Array<{ label: string }>;
|
||||
}): string {
|
||||
const desc = skill.description?.trim();
|
||||
const installLabel = skill.install[0]?.label?.trim();
|
||||
const combined =
|
||||
desc && installLabel ? `${desc} — ${installLabel}` : desc || installLabel;
|
||||
if (!combined) return "install";
|
||||
const maxLen = 90;
|
||||
return combined.length > maxLen
|
||||
? `${combined.slice(0, maxLen - 1)}…`
|
||||
: combined;
|
||||
}
|
||||
|
||||
function upsertSkillEntry(
|
||||
cfg: ClawdisConfig,
|
||||
skillKey: string,
|
||||
@ -104,7 +119,7 @@ export async function setupSkills(
|
||||
...installable.map((skill) => ({
|
||||
value: skill.name,
|
||||
label: `${skill.emoji ?? "🧩"} ${skill.name}`,
|
||||
hint: skill.install[0]?.label ?? "install",
|
||||
hint: formatSkillHint(skill),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
|
||||
@ -488,3 +488,50 @@ describe("talk.voiceAliases", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy config detection", () => {
|
||||
it("rejects routing.allowFrom", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Moved routing.allowFrom → whatsapp.allowFrom.",
|
||||
);
|
||||
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces legacy issues in snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(false);
|
||||
expect(snap.legacyIssues.length).toBe(1);
|
||||
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -58,6 +58,11 @@ export type WebConfig = {
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||
allowFrom?: string[];
|
||||
};
|
||||
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
@ -187,6 +192,17 @@ export type DiscordGuildEntry = {
|
||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||
};
|
||||
|
||||
export type DiscordSlashCommandConfig = {
|
||||
/** Enable handling for the configured slash command (default: false). */
|
||||
enabled?: boolean;
|
||||
/** Slash command name (default: "clawd"). */
|
||||
name?: string;
|
||||
/** Session key prefix for slash commands (default: "discord:slash"). */
|
||||
sessionPrefix?: string;
|
||||
/** Reply ephemerally (default: true). */
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** If false, do not start the Discord provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
@ -195,6 +211,7 @@ export type DiscordConfig = {
|
||||
historyLimit?: number;
|
||||
/** Allow agent-triggered Discord reactions (default: true). */
|
||||
enableReactions?: boolean;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
dm?: DiscordDmConfig;
|
||||
/** New per-guild config keyed by guild id or slug. */
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
@ -260,7 +277,6 @@ export type GroupChatConfig = {
|
||||
};
|
||||
|
||||
export type RoutingConfig = {
|
||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||
transcribeAudio?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
@ -335,6 +351,8 @@ export type GatewayAuthMode = "token" | "password";
|
||||
export type GatewayAuthConfig = {
|
||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||
mode?: GatewayAuthMode;
|
||||
/** Shared token for token mode (stored locally for CLI auth). */
|
||||
token?: string;
|
||||
/** Shared password for password mode (consider env instead). */
|
||||
password?: string;
|
||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||
@ -525,6 +543,7 @@ export type ClawdisConfig = {
|
||||
messages?: MessagesConfig;
|
||||
session?: SessionConfig;
|
||||
web?: WebConfig;
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
signal?: SignalConfig;
|
||||
@ -693,7 +712,6 @@ const HeartbeatSchema = z
|
||||
|
||||
const RoutingSchema = z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groupChat: GroupChatSchema,
|
||||
transcribeAudio: TranscribeAudioSchema,
|
||||
queue: z
|
||||
@ -909,6 +927,11 @@ const ClawdisSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
whatsapp: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
telegram: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@ -927,6 +950,14 @@ const ClawdisSchema = z.object({
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().optional(),
|
||||
slashCommand: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
sessionPrefix: z.string().optional(),
|
||||
ephemeral: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
enableReactions: z.boolean().optional(),
|
||||
@ -1068,6 +1099,7 @@ const ClawdisSchema = z.object({
|
||||
auth: z
|
||||
.object({
|
||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
||||
token: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
allowTailscale: z.boolean().optional(),
|
||||
})
|
||||
@ -1131,6 +1163,11 @@ export type ConfigValidationIssue = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LegacyConfigIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConfigFileSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
@ -1139,8 +1176,102 @@ export type ConfigFileSnapshot = {
|
||||
valid: boolean;
|
||||
config: ClawdisConfig;
|
||||
issues: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
|
||||
type LegacyConfigRule = {
|
||||
path: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
type LegacyConfigMigration = {
|
||||
id: string;
|
||||
describe: string;
|
||||
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||
};
|
||||
|
||||
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["routing", "allowFrom"],
|
||||
message:
|
||||
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
{
|
||||
id: "routing.allowFrom->whatsapp.allowFrom",
|
||||
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
||||
apply: (raw, changes) => {
|
||||
const routing = raw.routing;
|
||||
if (!routing || typeof routing !== "object") return;
|
||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||
if (allowFrom === undefined) return;
|
||||
|
||||
const whatsapp =
|
||||
raw.whatsapp && typeof raw.whatsapp === "object"
|
||||
? (raw.whatsapp as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (whatsapp.allowFrom === undefined) {
|
||||
whatsapp.allowFrom = allowFrom;
|
||||
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.allowFrom (whatsapp.allowFrom already set).",
|
||||
);
|
||||
}
|
||||
|
||||
delete (routing as Record<string, unknown>).allowFrom;
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
raw.whatsapp = whatsapp;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const root = raw as Record<string, unknown>;
|
||||
const issues: LegacyConfigIssue[] = [];
|
||||
for (const rule of LEGACY_CONFIG_RULES) {
|
||||
let cursor: unknown = root;
|
||||
for (const key of rule.path) {
|
||||
if (!cursor || typeof cursor !== "object") {
|
||||
cursor = undefined;
|
||||
break;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[key];
|
||||
}
|
||||
if (cursor !== undefined) {
|
||||
issues.push({ path: rule.path.join("."), message: rule.message });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function migrateLegacyConfig(raw: unknown): {
|
||||
config: ClawdisConfig | null;
|
||||
changes: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
|
||||
const next = structuredClone(raw) as Record<string, unknown>;
|
||||
const changes: string[] = [];
|
||||
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
|
||||
migration.apply(next, changes);
|
||||
}
|
||||
if (changes.length === 0) return { config: null, changes: [] };
|
||||
const validated = validateConfigObject(next);
|
||||
if (!validated.ok) {
|
||||
changes.push(
|
||||
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||
);
|
||||
return { config: null, changes };
|
||||
}
|
||||
return { config: validated.config, changes };
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@ -1199,6 +1330,16 @@ export function validateConfigObject(
|
||||
):
|
||||
| { ok: true; config: ClawdisConfig }
|
||||
| { ok: false; issues: ConfigValidationIssue[] } {
|
||||
const legacyIssues = findLegacyConfigIssues(raw);
|
||||
if (legacyIssues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: legacyIssues.map((iss) => ({
|
||||
path: iss.path,
|
||||
message: iss.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const validated = ClawdisSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
return {
|
||||
@ -1271,6 +1412,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const exists = fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey({});
|
||||
const legacyIssues: LegacyConfigIssue[] = [];
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
@ -1279,6 +1421,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: true,
|
||||
config,
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1296,9 +1439,12 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
issues: [
|
||||
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
||||
],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
|
||||
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
@ -1309,6 +1455,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: validated.issues,
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1320,6 +1467,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: true,
|
||||
config: applyTalkApiKey(validated.config),
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@ -1330,6 +1478,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -349,6 +349,8 @@ export function resolveSessionKey(
|
||||
ctx: MsgContext,
|
||||
mainKey?: string,
|
||||
) {
|
||||
const explicit = ctx.SessionKey?.trim();
|
||||
if (explicit) return explicit;
|
||||
const raw = deriveSessionKey(scope, ctx);
|
||||
if (scope === "global") return raw;
|
||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||
|
||||
@ -103,7 +103,7 @@ function resolveDeliveryTarget(
|
||||
|
||||
const sanitizedWhatsappTo = (() => {
|
||||
if (channel !== "whatsapp") return to;
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return to;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import {
|
||||
ApplicationCommandOptionType,
|
||||
ChannelType,
|
||||
Client,
|
||||
type CommandInteractionOption,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
type Message,
|
||||
@ -10,10 +12,12 @@ import {
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { DiscordSlashCommandConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, isVerbose, logVerbose } from "../globals.js";
|
||||
import { danger, isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
@ -25,6 +29,7 @@ export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
};
|
||||
@ -86,6 +91,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const dmConfig = cfg.discord?.dm;
|
||||
const guildEntries = cfg.discord?.guilds;
|
||||
const allowFrom = dmConfig?.allowFrom;
|
||||
const slashCommand = resolveSlashCommandConfig(
|
||||
opts.slashCommand ?? cfg.discord?.slashCommand,
|
||||
);
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const historyLimit = Math.max(
|
||||
@ -111,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
client.once(Events.ClientReady, () => {
|
||||
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
||||
if (slashCommand.enabled) {
|
||||
void ensureSlashCommand(client, slashCommand, runtime);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.Error, (err) => {
|
||||
@ -376,6 +387,163 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
try {
|
||||
if (!slashCommand.enabled) return;
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (interaction.commandName !== slashCommand.name) return;
|
||||
if (interaction.user?.bot) return;
|
||||
|
||||
const channelType = interaction.channel?.type as ChannelType | undefined;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isDirectMessage =
|
||||
!interaction.inGuild() && channelType === ChannelType.DM;
|
||||
const isGuildMessage = interaction.inGuild();
|
||||
|
||||
if (isGroupDm && !groupDmEnabled) return;
|
||||
if (isDirectMessage && !dmEnabled) return;
|
||||
|
||||
if (isGuildMessage) {
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: interaction.guild ?? null,
|
||||
guildEntries,
|
||||
});
|
||||
if (
|
||||
guildEntries &&
|
||||
Object.keys(guildEntries).length > 0 &&
|
||||
!guildInfo
|
||||
) {
|
||||
logVerbose(
|
||||
`Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const channelName =
|
||||
interaction.channel && "name" in interaction.channel
|
||||
? interaction.channel.name
|
||||
: undefined;
|
||||
const channelSlug = channelName
|
||||
? normalizeDiscordSlug(channelName)
|
||||
: "";
|
||||
const channelConfig = resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: interaction.channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
});
|
||||
if (channelConfig?.allowed === false) {
|
||||
logVerbose(
|
||||
`Blocked discord channel ${interaction.channelId} not in guild channel allowlist`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const userAllow = guildInfo?.users;
|
||||
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
||||
const users = normalizeDiscordAllowList(userAllow, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const userOk =
|
||||
!users ||
|
||||
allowListMatches(users, {
|
||||
id: interaction.user.id,
|
||||
name: interaction.user.username,
|
||||
tag: interaction.user.tag,
|
||||
});
|
||||
if (!userOk) {
|
||||
logVerbose(
|
||||
`Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (isGroupDm) {
|
||||
const channelName =
|
||||
interaction.channel && "name" in interaction.channel
|
||||
? interaction.channel.name
|
||||
: undefined;
|
||||
const channelSlug = channelName
|
||||
? normalizeDiscordSlug(channelName)
|
||||
: "";
|
||||
const groupDmAllowed = resolveGroupDmAllow({
|
||||
channels: groupDmChannels,
|
||||
channelId: interaction.channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
});
|
||||
if (!groupDmAllowed) return;
|
||||
} else if (isDirectMessage) {
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const allowList = normalizeDiscordAllowList(allowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const permitted =
|
||||
allowList &&
|
||||
allowListMatches(allowList, {
|
||||
id: interaction.user.id,
|
||||
name: interaction.user.username,
|
||||
tag: interaction.user.tag,
|
||||
});
|
||||
if (!permitted) {
|
||||
logVerbose(
|
||||
`Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = resolveSlashPrompt(interaction.options.data);
|
||||
if (!prompt) {
|
||||
await interaction.reply({
|
||||
content: "Message required.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: slashCommand.ephemeral });
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const ctxPayload = {
|
||||
Body: prompt,
|
||||
From: `discord:${userId}`,
|
||||
To: `slash:${userId}`,
|
||||
ChatType: "direct",
|
||||
SenderName: interaction.user.username,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: true,
|
||||
MessageSid: interaction.id,
|
||||
Timestamp: interaction.createdTimestamp,
|
||||
SessionKey: `${slashCommand.sessionPrefix}:${userId}`,
|
||||
};
|
||||
|
||||
const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
|
||||
const replies = replyResult
|
||||
? Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
|
||||
await deliverSlashReplies({
|
||||
replies,
|
||||
interaction,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`slash handler failed: ${String(err)}`));
|
||||
if (interaction.isRepliable()) {
|
||||
const content = "Sorry, something went wrong handling that command.";
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.followUp({ content, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@ -614,6 +782,86 @@ export function resolveGroupDmAllow(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSlashCommand(
|
||||
client: Client,
|
||||
slashCommand: Required<DiscordSlashCommandConfig>,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
try {
|
||||
const appCommands = client.application?.commands;
|
||||
if (!appCommands) {
|
||||
runtime.error?.(danger("discord slash commands unavailable"));
|
||||
return;
|
||||
}
|
||||
const existing = await appCommands.fetch();
|
||||
const hasCommand = Array.from(existing.values()).some(
|
||||
(entry) => entry.name === slashCommand.name,
|
||||
);
|
||||
if (hasCommand) return;
|
||||
await appCommands.create({
|
||||
name: slashCommand.name,
|
||||
description: "Ask Clawdis a question",
|
||||
options: [
|
||||
{
|
||||
name: "prompt",
|
||||
description: "What should Clawdis help with?",
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
runtime.log?.(`registered discord slash command /${slashCommand.name}`);
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number | string })?.status;
|
||||
const code = (err as { code?: number | string })?.code;
|
||||
const message = String(err);
|
||||
const isRateLimit =
|
||||
status === 429 || code === 429 || /rate ?limit/i.test(message);
|
||||
const text = `discord slash command setup failed: ${message}`;
|
||||
if (isRateLimit) {
|
||||
logVerbose(text);
|
||||
runtime.error?.(warn(text));
|
||||
} else {
|
||||
runtime.error?.(danger(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlashCommandConfig(
|
||||
raw: DiscordSlashCommandConfig | undefined,
|
||||
): Required<DiscordSlashCommandConfig> {
|
||||
return {
|
||||
enabled: raw ? raw.enabled !== false : false,
|
||||
name: raw?.name?.trim() || "clawd",
|
||||
sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash",
|
||||
ephemeral: raw?.ephemeral !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlashPrompt(
|
||||
options: readonly CommandInteractionOption[],
|
||||
): string | undefined {
|
||||
const direct = findFirstStringOption(options);
|
||||
if (direct) return direct;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFirstStringOption(
|
||||
options: readonly CommandInteractionOption[],
|
||||
): string | undefined {
|
||||
for (const option of options) {
|
||||
if (typeof option.value === "string") {
|
||||
const trimmed = option.value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
if (option.options && option.options.length > 0) {
|
||||
const nested = findFirstStringOption(option.options);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function sendTyping(message: Message) {
|
||||
try {
|
||||
const channel = message.channel;
|
||||
@ -659,3 +907,45 @@ async function deliverReplies({
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverSlashReplies({
|
||||
replies,
|
||||
interaction,
|
||||
ephemeral,
|
||||
}: {
|
||||
replies: ReplyPayload[];
|
||||
interaction: import("discord.js").ChatInputCommandInteraction;
|
||||
ephemeral: boolean;
|
||||
}) {
|
||||
const messages: string[] = [];
|
||||
for (const payload of replies) {
|
||||
const textRaw = payload.text?.trim() ?? "";
|
||||
const text =
|
||||
textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined;
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const combined = [
|
||||
text ?? "",
|
||||
...mediaList.map((url) => url.trim()).filter(Boolean),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combined) continue;
|
||||
for (const chunk of chunkText(combined, 2000)) {
|
||||
messages.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
await interaction.editReply({
|
||||
content: "No response was generated for that command.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [first, ...rest] = messages;
|
||||
await interaction.editReply({ content: first });
|
||||
for (const message of rest) {
|
||||
await interaction.followUp({ content: message, ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
if (auth.mode === "token" && !auth.token) {
|
||||
throw new Error(
|
||||
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set",
|
||||
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)",
|
||||
);
|
||||
}
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
|
||||
@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
|
||||
): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const config = loadConfig();
|
||||
const remote =
|
||||
config.gateway?.mode === "remote" ? config.gateway.remote : undefined;
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway.remote : undefined;
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
@ -39,14 +39,20 @@ export async function callGateway<T = unknown>(
|
||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||
? opts.token.trim()
|
||||
: undefined) ||
|
||||
(typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined);
|
||||
(isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof config.gateway?.auth?.token === "string" &&
|
||||
config.gateway.auth.token.trim().length > 0
|
||||
? config.gateway.auth.token.trim()
|
||||
: undefined));
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
? opts.password.trim()
|
||||
: undefined) ||
|
||||
process.env.CLAWDIS_GATEWAY_PASSWORD ||
|
||||
process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
|
||||
(typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined);
|
||||
|
||||
@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||
let testHooksConfig: Record<string, unknown> | undefined;
|
||||
let testCanvasHostPort: number | undefined;
|
||||
let testLegacyIssues: Array<{ path: string; message: string }> = [];
|
||||
let testLegacyParsed: Record<string, unknown> = {};
|
||||
let testMigrationConfig: Record<string, unknown> | null = null;
|
||||
let testMigrationChanges: string[] = [];
|
||||
const testIsNixMode = vi.hoisted(() => ({ value: false }));
|
||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => {
|
||||
path.join(os.homedir(), ".clawdis", "clawdis.json");
|
||||
|
||||
const readConfigFileSnapshot = async () => {
|
||||
if (testLegacyIssues.length > 0) {
|
||||
return {
|
||||
path: resolveConfigPath(),
|
||||
exists: true,
|
||||
raw: JSON.stringify(testLegacyParsed ?? {}),
|
||||
parsed: testLegacyParsed ?? {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: testLegacyIssues.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
})),
|
||||
legacyIssues: testLegacyIssues,
|
||||
};
|
||||
}
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => {
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => {
|
||||
valid: true,
|
||||
config: parsed,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@ -186,27 +208,34 @@ vi.mock("../config/config.js", () => {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const writeConfigFile = async (cfg: Record<string, unknown>) => {
|
||||
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
|
||||
const configPath = resolveConfigPath();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||
await fs.writeFile(configPath, raw, "utf-8");
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
||||
isNixMode: false,
|
||||
get isNixMode() {
|
||||
return testIsNixMode.value;
|
||||
},
|
||||
migrateLegacyConfig: (raw: unknown) => ({
|
||||
config: testMigrationConfig ?? (raw as Record<string, unknown>),
|
||||
changes: testMigrationChanges,
|
||||
}),
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: testAllowFrom,
|
||||
},
|
||||
session: { mainKey: "main", store: testSessionStorePath },
|
||||
@ -279,6 +308,11 @@ beforeEach(async () => {
|
||||
testGatewayAuth = undefined;
|
||||
testHooksConfig = undefined;
|
||||
testCanvasHostPort = undefined;
|
||||
testLegacyIssues = [];
|
||||
testLegacyParsed = {};
|
||||
testMigrationConfig = null;
|
||||
testMigrationChanges = [];
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
resetAgentRunContextForTest();
|
||||
@ -516,6 +550,40 @@ describe("gateway server", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("auto-migrates legacy config on startup", async () => {
|
||||
(writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.();
|
||||
testLegacyIssues = [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
];
|
||||
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||
testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } };
|
||||
testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."];
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("fails in Nix mode when legacy config is present", async () => {
|
||||
testLegacyIssues = [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
];
|
||||
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||
testIsNixMode.value = true;
|
||||
|
||||
const port = await getFreePort();
|
||||
await expect(startGatewayServer(port)).rejects.toThrow(
|
||||
"Legacy config entries detected while running in Nix mode",
|
||||
);
|
||||
});
|
||||
|
||||
test("models.list returns model catalog", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [
|
||||
|
||||
@ -48,6 +48,7 @@ import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
migrateLegacyConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
STATE_DIR_CLAWDIS,
|
||||
@ -659,8 +660,6 @@ type DedupeEntry = {
|
||||
error?: ErrorShape;
|
||||
};
|
||||
|
||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
|
||||
function formatForLog(value: unknown): string {
|
||||
try {
|
||||
if (value instanceof Error) {
|
||||
@ -1322,6 +1321,31 @@ export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
if (configSnapshot.legacyIssues.length > 0) {
|
||||
if (isNixMode) {
|
||||
throw new Error(
|
||||
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
|
||||
);
|
||||
}
|
||||
const { config: migrated, changes } = migrateLegacyConfig(
|
||||
configSnapshot.parsed,
|
||||
);
|
||||
if (!migrated) {
|
||||
throw new Error(
|
||||
'Legacy config entries detected but auto-migration failed. Run "clawdis doctor" to migrate.',
|
||||
);
|
||||
}
|
||||
await writeConfigFile(migrated);
|
||||
if (changes.length > 0) {
|
||||
log.info(
|
||||
`gateway: migrated legacy config entries:\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cfgAtStart = loadConfig();
|
||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||
@ -1345,7 +1369,8 @@ export async function startGatewayServer(
|
||||
...tailscaleOverrides,
|
||||
};
|
||||
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
||||
const token = getGatewayToken();
|
||||
const token =
|
||||
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
|
||||
const password =
|
||||
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
|
||||
const authMode: ResolvedGatewayAuth["mode"] =
|
||||
@ -2228,6 +2253,7 @@ export async function startGatewayServer(
|
||||
token: discordToken.trim(),
|
||||
runtime: discordRuntimeEnv,
|
||||
abortSignal: discordAbort.signal,
|
||||
slashCommand: cfg.discord?.slashCommand,
|
||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||
historyLimit: cfg.discord?.historyLimit,
|
||||
})
|
||||
@ -6641,7 +6667,7 @@ export async function startGatewayServer(
|
||||
if (explicit) return resolvedTo;
|
||||
|
||||
const cfg = cfgForAgent ?? loadConfig();
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return resolvedTo;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
|
||||
@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
||||
|
||||
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
||||
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
it("applies allowFrom fallback for WhatsApp targets", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
|
||||
routing: { allowFrom: ["+1555", "+1666"] },
|
||||
whatsapp: { allowFrom: ["+1555", "+1666"] },
|
||||
};
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => {
|
||||
agent: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => {
|
||||
agent: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
|
||||
@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
return { channel, to };
|
||||
}
|
||||
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return { channel, to };
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
||||
const previousUpdatedAt = entry?.updatedAt;
|
||||
const allowFrom = cfg.routing?.allowFrom ?? [];
|
||||
const allowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||
const sender = resolveHeartbeatSender({
|
||||
allowFrom,
|
||||
lastTo: entry?.lastTo,
|
||||
|
||||
@ -80,8 +80,8 @@ export async function buildProviderSummary(
|
||||
);
|
||||
}
|
||||
|
||||
const allowFrom = effective.routing?.allowFrom?.length
|
||||
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean)
|
||||
const allowFrom = effective.whatsapp?.allowFrom?.length
|
||||
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
|
||||
: [];
|
||||
if (allowFrom.length) {
|
||||
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import JSZip from "jszip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { detectMime } from "./mime.js";
|
||||
import { detectMime, imageMimeFromFormat } from "./mime.js";
|
||||
|
||||
async function makeOoxmlZip(opts: {
|
||||
mainMime: string;
|
||||
@ -17,6 +17,15 @@ async function makeOoxmlZip(opts: {
|
||||
}
|
||||
|
||||
describe("mime detection", () => {
|
||||
it("maps common image formats to mime types", () => {
|
||||
expect(imageMimeFromFormat("jpg")).toBe("image/jpeg");
|
||||
expect(imageMimeFromFormat("jpeg")).toBe("image/jpeg");
|
||||
expect(imageMimeFromFormat("png")).toBe("image/png");
|
||||
expect(imageMimeFromFormat("webp")).toBe("image/webp");
|
||||
expect(imageMimeFromFormat("gif")).toBe("image/gif");
|
||||
expect(imageMimeFromFormat("unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects docx from buffer", async () => {
|
||||
const buf = await makeOoxmlZip({
|
||||
mainMime:
|
||||
|
||||
@ -107,6 +107,25 @@ export function extensionForMime(mime?: string | null): string | undefined {
|
||||
return EXT_BY_MIME[mime.toLowerCase()];
|
||||
}
|
||||
|
||||
export function imageMimeFromFormat(
|
||||
format?: string | null,
|
||||
): string | undefined {
|
||||
if (!format) return undefined;
|
||||
switch (format.toLowerCase()) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function kindFromMime(mime?: string | null): MediaKind {
|
||||
return mediaKindFromMime(mime);
|
||||
}
|
||||
|
||||
@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined {
|
||||
|
||||
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
||||
const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ export function normalizeE164(number: string): string {
|
||||
|
||||
/**
|
||||
* "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account,
|
||||
* and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
||||
* and `whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
||||
* "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
|
||||
*/
|
||||
export function isSelfChatMode(
|
||||
|
||||
@ -111,7 +111,7 @@ describe("partial reply gating", () => {
|
||||
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
|
||||
|
||||
const mockConfig: ClawdisConfig = {
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
};
|
||||
@ -158,7 +158,7 @@ describe("partial reply gating", () => {
|
||||
const replyResolver = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockConfig: ClawdisConfig = {
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: store.storePath, mainKey: "main" },
|
||||
@ -1097,9 +1097,11 @@ describe("web auto-reply", () => {
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164.
|
||||
allowFrom: ["+999"],
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
requireMention: true,
|
||||
mentionPatterns: ["\\bclawd\\b"],
|
||||
@ -1247,7 +1249,7 @@ describe("web auto-reply", () => {
|
||||
it("prefixes body with same-phone marker when from === to", async () => {
|
||||
// Enable messagePrefix for same-phone mode testing
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -1372,7 +1374,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("applies responsePrefix to regular replies", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -1417,7 +1419,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("does not deliver HEARTBEAT_OK responses", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -1462,7 +1464,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("does not double-prefix if responsePrefix already present", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -1508,7 +1510,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("sends tool summaries immediately with responsePrefix", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
|
||||
@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||
}
|
||||
})
|
||||
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
||||
return { mentionRegexes, allowFrom: cfg.routing?.allowFrom };
|
||||
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
||||
}
|
||||
|
||||
function isBotMentioned(
|
||||
@ -448,8 +448,8 @@ export function resolveHeartbeatRecipients(
|
||||
|
||||
const sessionRecipients = getSessionRecipients(cfg);
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0
|
||||
? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||
Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0
|
||||
? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||
: [];
|
||||
|
||||
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
||||
@ -918,7 +918,7 @@ export async function monitorWebProvider(
|
||||
// Build message prefix: explicit config > default based on allowFrom
|
||||
let messagePrefix = cfg.messages?.messagePrefix;
|
||||
if (messagePrefix === undefined) {
|
||||
const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0;
|
||||
const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0;
|
||||
messagePrefix = hasAllowFrom ? "" : "[clawdis]";
|
||||
}
|
||||
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
||||
|
||||
@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"], // Allow all in tests
|
||||
},
|
||||
messages: {
|
||||
|
||||
@ -157,7 +157,7 @@ export async function monitorWebInbox(options: {
|
||||
// Filter unauthorized senders early to prevent wasted processing
|
||||
// and potential session corruption from Bad MAC errors
|
||||
const cfg = loadConfig();
|
||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
||||
const configuredAllowFrom = cfg.whatsapp?.allowFrom;
|
||||
// Without user config, default to self-only DM access so the owner can talk to themselves
|
||||
const defaultAllowFrom =
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164
|
||||
|
||||
@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({
|
||||
}));
|
||||
|
||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"], // Allow all in tests by default
|
||||
},
|
||||
messages: {
|
||||
@ -450,7 +450,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111"], // does not include +777
|
||||
},
|
||||
messages: {
|
||||
@ -506,7 +506,7 @@ describe("web monitor inbox", () => {
|
||||
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
|
||||
// from unauthorized senders corrupting sessions
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111"], // Only allow +111
|
||||
},
|
||||
messages: {
|
||||
@ -546,7 +546,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -561,7 +561,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("skips read receipts in self-chat mode", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164 (+123).
|
||||
allowFrom: ["+123"],
|
||||
},
|
||||
@ -598,7 +598,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -613,7 +613,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("lets group messages through even when sender not in allowFrom", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234"],
|
||||
},
|
||||
messages: {
|
||||
@ -655,7 +655,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("allows messages from senders in allowFrom list", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111", "+999"], // Allow +999
|
||||
},
|
||||
messages: {
|
||||
@ -690,7 +690,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@ -707,7 +707,7 @@ describe("web monitor inbox", () => {
|
||||
// Same-phone mode: when from === selfJid, should always be allowed
|
||||
// This allows users to message themselves even with restrictive allowFrom
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111"], // Only allow +111, but self is +123
|
||||
},
|
||||
messages: {
|
||||
@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
|
||||
@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js";
|
||||
// Use globalThis to store the mock config so it survives vi.mock hoisting
|
||||
const CONFIG_KEY = Symbol.for("clawdis:testConfigMock");
|
||||
const DEFAULT_CONFIG = {
|
||||
routing: {
|
||||
whatsapp: {
|
||||
// Tests can override; default remains open to avoid surprising fixtures
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
|
||||
@ -186,6 +186,7 @@ export class ClawdisApp extends LitElement {
|
||||
this.syncThemeWithSettings();
|
||||
this.attachThemeListener();
|
||||
window.addEventListener("popstate", this.popStateHandler);
|
||||
this.applySettingsFromUrl();
|
||||
this.connect();
|
||||
this.startNodesPolling();
|
||||
}
|
||||
@ -334,6 +335,20 @@ export class ClawdisApp extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private applySettingsFromUrl() {
|
||||
if (!window.location.search) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token")?.trim();
|
||||
if (!token) return;
|
||||
if (!this.settings.token) {
|
||||
this.applySettings({ ...this.settings, token });
|
||||
}
|
||||
params.delete("token");
|
||||
const url = new URL(window.location.href);
|
||||
url.search = params.toString();
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
setTab(next: Tab) {
|
||||
if (this.tab !== next) this.tab = next;
|
||||
void this.refreshActiveTab();
|
||||
|
||||
@ -90,4 +90,13 @@ describe("control UI routing", () => {
|
||||
expect(maxScroll).toBeGreaterThan(0);
|
||||
expect(container.scrollTop).toBe(maxScroll);
|
||||
});
|
||||
|
||||
it("hydrates token from URL params and strips it", async () => {
|
||||
const app = mountApp("/ui/overview?token=abc123");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(window.location.pathname).toBe("/ui/overview");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user