mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
55 Commits
v2026.2.2
...
fix/imessa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b782ae104d | ||
|
|
27525586b2 | ||
|
|
78fd194722 | ||
|
|
b2361292e7 | ||
|
|
57566c5e4d | ||
|
|
da6de49815 | ||
|
|
6341819d74 | ||
|
|
c396877dd9 | ||
|
|
79d00e20db | ||
|
|
f8d2534062 | ||
|
|
6fb8d8850e | ||
|
|
246896d64b | ||
|
|
64df61f697 | ||
|
|
ef4949b936 | ||
|
|
1409943863 | ||
|
|
3f82daefd8 | ||
|
|
ab9f06f4ff | ||
|
|
0bb0dfc9bc | ||
|
|
511c656cbc | ||
|
|
3a03e38378 | ||
|
|
a749db9820 | ||
|
|
fa4b28d7af | ||
|
|
5292367324 | ||
|
|
35eb40a700 | ||
|
|
6fdb136688 | ||
|
|
44d1aa31f3 | ||
|
|
7b3d23b703 | ||
|
|
efc9d0a498 | ||
|
|
089d03453d | ||
|
|
4a5d368926 | ||
|
|
1c6b25ddbb | ||
|
|
9f16de2533 | ||
|
|
d2ff28dda7 | ||
|
|
f04e84f194 | ||
|
|
5af322f710 | ||
|
|
b64c1a56a1 | ||
|
|
41a4f1200b | ||
|
|
202c554d09 | ||
|
|
16349b6e93 | ||
|
|
efb4a34be4 | ||
|
|
e4b084c762 | ||
|
|
3e6c623cfe | ||
|
|
9c4eab69cc | ||
|
|
9c5941ba46 | ||
|
|
41d2993f7b | ||
|
|
d3ba57b7d7 | ||
|
|
6b4b6049b4 | ||
|
|
bbe9cb3022 | ||
|
|
61a7fc5e0e | ||
|
|
e895e85f54 | ||
|
|
e59eb814bd | ||
|
|
df55eeacdb | ||
|
|
fd8f8843bd | ||
|
|
a9bb96ade3 | ||
|
|
f1cbe7db1d |
@@ -28,6 +28,14 @@
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Docs i18n (zh-CN)
|
||||
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -2,21 +2,80 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.3
|
||||
|
||||
### Changes
|
||||
|
||||
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
|
||||
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
|
||||
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
|
||||
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
|
||||
- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs.
|
||||
- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI.
|
||||
- Cron: suppress messaging tools during announce delivery so summaries post consistently.
|
||||
- Cron: avoid duplicate deliveries when isolated runs send messages directly.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.
|
||||
- iMessage: skip echo replies using recent outbound IDs before falling back to text matching. (#8680) Thanks @Iranb.
|
||||
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
|
||||
- Web UI: apply button styling to the new-messages indicator.
|
||||
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
|
||||
- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.
|
||||
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
|
||||
- Cron: reload store data when the store file is recreated or mtime changes.
|
||||
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||
|
||||
## 2026.2.2-3
|
||||
|
||||
### Fixes
|
||||
|
||||
- Update: ship legacy daemon-cli shim for pre-tsdown update imports (fixes daemon restart after npm update).
|
||||
|
||||
## 2026.2.2-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: promote BlueBubbles as the recommended iMessage integration; mark imsg channel as legacy. (#8415) Thanks @tyler6204.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI status: resolve build-info from bundled dist output (fixes "unknown" commit in npm builds).
|
||||
|
||||
## 2026.2.2-1
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI status: fall back to build-info for version detection (fixes "unknown" in beta builds). Thanks @gumadeira.
|
||||
|
||||
## 2026.2.2
|
||||
|
||||
### Changes
|
||||
|
||||
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
|
||||
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
|
||||
- Subagents: discourage direct messaging tool use unless a specific external recipient is requested.
|
||||
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
|
||||
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
||||
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
|
||||
- Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo.
|
||||
- Docs: add zh-CN i18n guardrails to avoid editing generated translations. (#8416) Thanks @joshp123.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security: require operator.approvals for gateway /approve commands. (#1) Thanks @mitsuhiko, @yueyueL.
|
||||
- Security: Matrix allowlists now require full MXIDs; ambiguous name resolution no longer grants access. Thanks @MegaManSec.
|
||||
- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir.
|
||||
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed).
|
||||
- Onboarding: drop completion prompt now handled by install/update.
|
||||
- TUI: block onboarding output while TUI is active and restore terminal state on exit.
|
||||
- CLI: cache shell completion scripts in state dir and source cached files in profiles.
|
||||
- Zsh completion: escape option descriptions to avoid invalid option errors.
|
||||
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
|
||||
- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001)
|
||||
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
|
||||
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
|
||||
14
README.md
14
README.md
@@ -120,7 +120,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -144,7 +144,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [iMessage](https://docs.openclaw.ai/channels/imessage) (imsg), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -375,9 +375,15 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [iMessage](https://docs.openclaw.ai/channels/imessage)
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602020
|
||||
versionName = "2026.2.2"
|
||||
versionCode = 202602030
|
||||
versionName = "2026.2.3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.2</string>
|
||||
<string>2026.2.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.2</string>
|
||||
<string>2026.2.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
</dict>
|
||||
|
||||
@@ -81,7 +81,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.2"
|
||||
CFBundleShortVersionString: "2026.2.3"
|
||||
CFBundleVersion: "20260202"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.2"
|
||||
CFBundleShortVersionString: "2026.2.3"
|
||||
CFBundleVersion: "20260202"
|
||||
|
||||
@@ -20,9 +20,11 @@ extension CronJobEditor {
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
switch job.schedule {
|
||||
case let .at(atMs):
|
||||
case let .at(at):
|
||||
self.scheduleKind = .at
|
||||
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
if let date = CronSchedule.parseAtDate(at) {
|
||||
self.atDate = date
|
||||
}
|
||||
case let .every(everyMs, _):
|
||||
self.scheduleKind = .every
|
||||
self.everyText = self.formatDuration(ms: everyMs)
|
||||
@@ -36,19 +38,22 @@ extension CronJobEditor {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
|
||||
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||
if let delivery = job.delivery {
|
||||
self.deliveryMode = delivery.mode == .announce ? .announce : .none
|
||||
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = delivery.to ?? ""
|
||||
self.bestEffortDeliver = delivery.bestEffort ?? false
|
||||
} else if self.sessionTarget == .isolated {
|
||||
self.deliveryMode = .announce
|
||||
}
|
||||
}
|
||||
|
||||
func save() {
|
||||
@@ -88,15 +93,29 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
root["delivery"] = self.buildDelivery()
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildDelivery() -> [String: Any] {
|
||||
let mode = self.deliveryMode == .announce ? "announce" : "none"
|
||||
var delivery: [String: Any] = ["mode": mode]
|
||||
if self.deliveryMode == .announce {
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { delivery["to"] = to }
|
||||
if self.bestEffortDeliver {
|
||||
delivery["bestEffort"] = true
|
||||
} else if self.job?.delivery?.bestEffort == true {
|
||||
delivery["bestEffort"] = false
|
||||
}
|
||||
}
|
||||
return delivery
|
||||
}
|
||||
|
||||
func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
@@ -115,7 +134,7 @@ extension CronJobEditor {
|
||||
func buildSchedule() throws -> [String: Any] {
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
@@ -209,14 +228,6 @@ extension CronJobEditor {
|
||||
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !thinking.isEmpty { payload["thinking"] = thinking }
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,12 @@ extension CronJobEditor {
|
||||
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.deliveryMode = .announce
|
||||
self.channel = "last"
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
self.bestEffortDeliver = true
|
||||
self.postPrefix = "Cron"
|
||||
|
||||
_ = self.buildAgentTurnPayload()
|
||||
_ = try? self.buildPayload()
|
||||
|
||||
@@ -16,16 +16,13 @@ struct CronJobEditor: View {
|
||||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
static let sessionTargetNote =
|
||||
"Main jobs post a system event into the current main session. "
|
||||
+ "Isolated jobs run OpenClaw in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel."
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
"Isolated jobs always run an agent turn. Announce sends a short summary to a channel."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
static let mainSummaryNote =
|
||||
"Controls the label used when posting the completion summary back to the main session."
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@@ -46,13 +43,13 @@ struct CronJobEditor: View {
|
||||
@State var payloadKind: PayloadKind = .systemEvent
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
|
||||
@State var deliveryMode: DeliveryChoice = .announce
|
||||
@State var channel: String = "last"
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@State var bestEffortDeliver: Bool = false
|
||||
@State var postPrefix: String = "Cron"
|
||||
|
||||
var channelOptions: [String] {
|
||||
let ordered = self.channelsStore.orderedChannelIds()
|
||||
@@ -248,27 +245,6 @@ struct CronJobEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
GroupBox("Main session summary") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Prefix")
|
||||
TextField("Cron", text: self.$postPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.mainSummaryNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 2)
|
||||
@@ -340,13 +316,17 @@ struct CronJobEditor: View {
|
||||
.frame(width: 180, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deliver")
|
||||
Toggle("Deliver result to a channel", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
self.gridLabel("Delivery")
|
||||
Picker("", selection: self.$deliveryMode) {
|
||||
Text("Announce summary").tag(DeliveryChoice.announce)
|
||||
Text("None").tag(DeliveryChoice.none)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
if self.deliver {
|
||||
if self.deliveryMode == .announce {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
@@ -367,7 +347,7 @@ struct CronJobEditor: View {
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Best-effort")
|
||||
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
|
||||
Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,26 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
|
||||
case none
|
||||
case announce
|
||||
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
struct CronDelivery: Codable, Equatable {
|
||||
var mode: CronDeliveryMode
|
||||
var channel: String?
|
||||
var to: String?
|
||||
var bestEffort: Bool?
|
||||
}
|
||||
|
||||
enum CronSchedule: Codable, Equatable {
|
||||
case at(atMs: Int)
|
||||
case at(at: String)
|
||||
case every(everyMs: Int, anchorMs: Int?)
|
||||
case cron(expr: String, tz: String?)
|
||||
|
||||
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
|
||||
enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz }
|
||||
|
||||
var kind: String {
|
||||
switch self {
|
||||
@@ -34,7 +48,21 @@ enum CronSchedule: Codable, Equatable {
|
||||
let kind = try container.decode(String.self, forKey: .kind)
|
||||
switch kind {
|
||||
case "at":
|
||||
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
|
||||
if let at = try container.decodeIfPresent(String.self, forKey: .at),
|
||||
!at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
self = .at(at: at)
|
||||
return
|
||||
}
|
||||
if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
self = .at(at: Self.formatIsoDate(date))
|
||||
return
|
||||
}
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .at,
|
||||
in: container,
|
||||
debugDescription: "Missing schedule.at")
|
||||
case "every":
|
||||
self = try .every(
|
||||
everyMs: container.decode(Int.self, forKey: .everyMs),
|
||||
@@ -55,8 +83,8 @@ enum CronSchedule: Codable, Equatable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.kind, forKey: .kind)
|
||||
switch self {
|
||||
case let .at(atMs):
|
||||
try container.encode(atMs, forKey: .atMs)
|
||||
case let .at(at):
|
||||
try container.encode(at, forKey: .at)
|
||||
case let .every(everyMs, anchorMs):
|
||||
try container.encode(everyMs, forKey: .everyMs)
|
||||
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
|
||||
@@ -65,6 +93,29 @@ enum CronSchedule: Codable, Equatable {
|
||||
try container.encodeIfPresent(tz, forKey: .tz)
|
||||
}
|
||||
}
|
||||
|
||||
static func parseAtDate(_ value: String) -> Date? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if let date = isoFormatterWithFractional.date(from: trimmed) { return date }
|
||||
return isoFormatter.date(from: trimmed)
|
||||
}
|
||||
|
||||
static func formatIsoDate(_ date: Date) -> String {
|
||||
isoFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
enum CronPayload: Codable, Equatable {
|
||||
@@ -131,10 +182,6 @@ enum CronPayload: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CronIsolation: Codable, Equatable {
|
||||
var postToMainPrefix: String?
|
||||
}
|
||||
|
||||
struct CronJobState: Codable, Equatable {
|
||||
var nextRunAtMs: Int?
|
||||
var runningAtMs: Int?
|
||||
@@ -157,7 +204,7 @@ struct CronJob: Identifiable, Codable, Equatable {
|
||||
let sessionTarget: CronSessionTarget
|
||||
let wakeMode: CronWakeMode
|
||||
let payload: CronPayload
|
||||
let isolation: CronIsolation?
|
||||
let delivery: CronDelivery?
|
||||
let state: CronJobState
|
||||
|
||||
var displayName: String {
|
||||
|
||||
@@ -17,9 +17,11 @@ extension CronSettings {
|
||||
|
||||
func scheduleSummary(_ schedule: CronSchedule) -> String {
|
||||
switch schedule {
|
||||
case let .at(atMs):
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
case let .at(at):
|
||||
if let date = CronSchedule.parseAtDate(at) {
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
}
|
||||
return "at \(at)"
|
||||
case let .every(everyMs, _):
|
||||
return "every \(self.formatDuration(ms: everyMs))"
|
||||
case let .cron(expr, tz):
|
||||
|
||||
@@ -128,7 +128,7 @@ extension CronSettings {
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
self.payloadSummary(job.payload)
|
||||
self.payloadSummary(job)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
@@ -205,7 +205,8 @@ extension CronSettings {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
func payloadSummary(_ payload: CronPayload) -> some View {
|
||||
func payloadSummary(_ job: CronJob) -> some View {
|
||||
let payload = job.payload
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Payload")
|
||||
.font(.caption.weight(.semibold))
|
||||
@@ -215,7 +216,7 @@ extension CronSettings {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
@@ -223,10 +224,19 @@ extension CronSettings {
|
||||
HStack(spacing: 8) {
|
||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if deliver ?? false {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
if job.sessionTarget == .isolated {
|
||||
let delivery = job.delivery
|
||||
if let delivery {
|
||||
if delivery.mode == .announce {
|
||||
StatusPill(text: "announce", tint: .secondary)
|
||||
if let channel = delivery.channel, !channel.isEmpty {
|
||||
StatusPill(text: channel, tint: .secondary)
|
||||
}
|
||||
if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
} else {
|
||||
StatusPill(text: "no delivery", tint: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
message: "Summarize inbox",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
channel: "last",
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
||||
runningAtMs: nil,
|
||||
@@ -75,11 +75,11 @@ extension CronSettings {
|
||||
message: "Summarize",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
@@ -111,7 +111,7 @@ extension CronSettings {
|
||||
_ = view.detailCard(job)
|
||||
_ = view.runHistoryCard(job)
|
||||
_ = view.runRow(run)
|
||||
_ = view.payloadSummary(job.payload)
|
||||
_ = view.payloadSummary(job)
|
||||
_ = view.scheduleSummary(job.schedule)
|
||||
_ = view.statusTint(job.state.lastStatus)
|
||||
_ = view.nextRunLabel(Date())
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.2</string>
|
||||
<string>2026.2.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602020</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?,
|
||||
delivery: [String: AnyCodable]?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
self.state = state
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
case state
|
||||
}
|
||||
}
|
||||
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?
|
||||
delivery: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ struct CronJobEditorSmokeTests {
|
||||
message: "Summarize the last day",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_100_000,
|
||||
runningAtMs: nil,
|
||||
|
||||
@@ -5,12 +5,24 @@ import Testing
|
||||
@Suite
|
||||
struct CronModelsTests {
|
||||
@Test func scheduleAtEncodesAndDecodes() throws {
|
||||
let schedule = CronSchedule.at(atMs: 123)
|
||||
let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z")
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
|
||||
#expect(decoded == schedule)
|
||||
}
|
||||
|
||||
@Test func scheduleAtDecodesLegacyAtMs() throws {
|
||||
let json = """
|
||||
{"kind":"at","atMs":1700000000000}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
|
||||
if case let .at(at) = decoded {
|
||||
#expect(at.hasPrefix("2023-"))
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func scheduleEveryEncodesAndDecodesWithAnchor() throws {
|
||||
let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000)
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
@@ -49,11 +61,11 @@ struct CronModelsTests {
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 1_700_000_000_000),
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState())
|
||||
let data = try JSONEncoder().encode(job)
|
||||
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
|
||||
@@ -62,7 +74,7 @@ struct CronModelsTests {
|
||||
|
||||
@Test func scheduleDecodeRejectsUnknownKind() {
|
||||
let json = """
|
||||
{"kind":"wat","atMs":1}
|
||||
{"kind":"wat","at":"2026-02-03T18:00:00Z"}
|
||||
"""
|
||||
#expect(throws: DecodingError.self) {
|
||||
_ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
|
||||
@@ -88,11 +100,11 @@ struct CronModelsTests {
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "hi"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState())
|
||||
#expect(base.displayName == "hello")
|
||||
|
||||
@@ -111,11 +123,11 @@ struct CronModelsTests {
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "hi"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_000_000,
|
||||
runningAtMs: nil,
|
||||
|
||||
@@ -23,7 +23,7 @@ struct SettingsViewSmokeTests {
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
delivery: nil,
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
@@ -48,11 +48,11 @@ struct SettingsViewSmokeTests {
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: true,
|
||||
channel: "sms",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: nil,
|
||||
runningAtMs: nil,
|
||||
|
||||
@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?,
|
||||
delivery: [String: AnyCodable]?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
self.state = state
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
case state
|
||||
}
|
||||
}
|
||||
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let delivery: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?
|
||||
delivery: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.delivery = delivery
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case delivery
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
667
docs/assets/docs-chat-widget.js
Normal file
667
docs/assets/docs-chat-widget.js
Normal file
@@ -0,0 +1,667 @@
|
||||
(() => {
|
||||
if (document.getElementById("docs-chat-root")) return;
|
||||
|
||||
// Determine if we're on the docs site or embedded elsewhere
|
||||
const hostname = window.location.hostname;
|
||||
const isDocsSite = hostname === "localhost" || hostname === "127.0.0.1" ||
|
||||
hostname.includes("docs.openclaw") || hostname.endsWith(".mintlify.app");
|
||||
const assetsBase = isDocsSite ? "" : "https://docs.openclaw.ai";
|
||||
const apiBase = "https://claw-api.openknot.ai/api";
|
||||
|
||||
// Load marked for markdown rendering (via CDN)
|
||||
let markedReady = false;
|
||||
const loadMarkdownLib = () => {
|
||||
if (window.marked) {
|
||||
markedReady = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.jsdelivr.net/npm/marked@15.0.6/marked.min.js";
|
||||
script.onload = () => {
|
||||
if (window.marked) {
|
||||
markedReady = true;
|
||||
}
|
||||
};
|
||||
script.onerror = () => console.warn("Failed to load marked library");
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
loadMarkdownLib();
|
||||
|
||||
// Markdown renderer with fallback before module loads
|
||||
const renderMarkdown = (text) => {
|
||||
if (markedReady && window.marked) {
|
||||
// Configure marked for security: disable HTML pass-through
|
||||
const html = window.marked.parse(text, { async: false, gfm: true, breaks: true });
|
||||
// Open links in new tab by rewriting <a> tags
|
||||
return html.replace(/<a href="/g, '<a target="_blank" rel="noopener" href="');
|
||||
}
|
||||
// Fallback: escape HTML and preserve newlines
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n/g, "<br>");
|
||||
};
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
#docs-chat-root { position: fixed; right: 20px; bottom: 20px; z-index: 9999; font-family: var(--font-body, system-ui, -apple-system, sans-serif); }
|
||||
#docs-chat-root.docs-chat-expanded { right: 0; bottom: 0; top: 0; }
|
||||
/* Thin scrollbar styling */
|
||||
#docs-chat-root ::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
#docs-chat-root ::-webkit-scrollbar-track { background: transparent; }
|
||||
#docs-chat-root ::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); border-radius: 3px; }
|
||||
#docs-chat-root ::-webkit-scrollbar-thumb:hover { background: var(--docs-chat-muted); }
|
||||
#docs-chat-root * { scrollbar-width: thin; scrollbar-color: var(--docs-chat-panel-border) transparent; }
|
||||
:root {
|
||||
--docs-chat-accent: var(--accent, #ff7d60);
|
||||
--docs-chat-text: #1a1a1a;
|
||||
--docs-chat-muted: #555;
|
||||
--docs-chat-panel: rgba(255, 255, 255, 0.92);
|
||||
--docs-chat-panel-border: rgba(0, 0, 0, 0.1);
|
||||
--docs-chat-surface: rgba(250, 250, 250, 0.95);
|
||||
--docs-chat-shadow: 0 18px 50px rgba(0,0,0,0.15);
|
||||
--docs-chat-code-bg: rgba(0, 0, 0, 0.05);
|
||||
--docs-chat-assistant-bg: #f5f5f5;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--docs-chat-text: #e8e8e8;
|
||||
--docs-chat-muted: #aaa;
|
||||
--docs-chat-panel: rgba(28, 28, 30, 0.95);
|
||||
--docs-chat-panel-border: rgba(255, 255, 255, 0.12);
|
||||
--docs-chat-surface: rgba(38, 38, 40, 0.95);
|
||||
--docs-chat-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
||||
--docs-chat-code-bg: rgba(255, 255, 255, 0.08);
|
||||
--docs-chat-assistant-bg: #2a2a2c;
|
||||
}
|
||||
#docs-chat-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(140deg, rgba(255,90,54,0.25), rgba(255,90,54,0.06));
|
||||
color: var(--docs-chat-text);
|
||||
border: 1px solid rgba(255,90,54,0.4);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 30px rgba(255,90,54, 0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
font-family: var(--font-pixel, var(--font-body, system-ui, sans-serif));
|
||||
}
|
||||
#docs-chat-button span { font-weight: 600; letter-spacing: 0.04em; font-size: 14px; }
|
||||
.docs-chat-logo { width: 20px; height: 20px; }
|
||||
#docs-chat-panel {
|
||||
width: min(440px, calc(100vw - 40px));
|
||||
height: min(696px, calc(100vh - 80px));
|
||||
background: var(--docs-chat-panel);
|
||||
color: var(--docs-chat-text);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--docs-chat-panel-border);
|
||||
box-shadow: var(--docs-chat-shadow);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
#docs-chat-root.docs-chat-expanded #docs-chat-panel {
|
||||
width: min(512px, 100vw);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
border-radius: 18px 0 0 18px;
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
#docs-chat-root.docs-chat-expanded #docs-chat-panel {
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
}
|
||||
#docs-chat-root.docs-chat-expanded { right: 0; left: 0; bottom: 0; top: 0; }
|
||||
}
|
||||
#docs-chat-header {
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel, var(--font-body, system-ui, sans-serif));
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: 1px solid var(--docs-chat-panel-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#docs-chat-header-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
#docs-chat-header-title span { color: var(--docs-chat-text); font-size: 15px; }
|
||||
#docs-chat-header-actions { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.docs-chat-icon-button {
|
||||
border: 1px solid var(--docs-chat-panel-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
#docs-chat-messages { flex: 1; padding: 12px 14px; overflow: auto; background: transparent; }
|
||||
#docs-chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--docs-chat-panel-border);
|
||||
background: var(--docs-chat-surface);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
#docs-chat-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 1px solid var(--docs-chat-panel-border);
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
color: var(--docs-chat-text);
|
||||
background: var(--docs-chat-surface);
|
||||
min-height: 42px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#docs-chat-input textarea::placeholder { color: var(--docs-chat-muted); }
|
||||
#docs-chat-send {
|
||||
background: var(--docs-chat-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
#docs-chat-send:hover { opacity: 0.9; }
|
||||
#docs-chat-send:active { opacity: 0.8; }
|
||||
.docs-chat-bubble {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
max-width: 92%;
|
||||
}
|
||||
.docs-chat-user {
|
||||
background: rgba(255, 125, 96, 0.15);
|
||||
color: var(--docs-chat-text);
|
||||
border: 1px solid rgba(255, 125, 96, 0.3);
|
||||
align-self: flex-end;
|
||||
white-space: pre-wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
html[data-theme="dark"] .docs-chat-user {
|
||||
background: rgba(255, 125, 96, 0.18);
|
||||
border-color: rgba(255, 125, 96, 0.35);
|
||||
}
|
||||
.docs-chat-assistant {
|
||||
background: var(--docs-chat-assistant-bg);
|
||||
color: var(--docs-chat-text);
|
||||
border: 1px solid var(--docs-chat-panel-border);
|
||||
}
|
||||
/* Markdown content styling for chat bubbles */
|
||||
.docs-chat-assistant p { margin: 0 0 10px 0; }
|
||||
.docs-chat-assistant p:last-child { margin-bottom: 0; }
|
||||
.docs-chat-assistant code {
|
||||
background: var(--docs-chat-code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.docs-chat-assistant pre {
|
||||
background: var(--docs-chat-code-bg);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 6px 0;
|
||||
font-size: 0.9em;
|
||||
max-width: 100%;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
}
|
||||
.docs-chat-assistant pre::-webkit-scrollbar-thumb { background: transparent; }
|
||||
.docs-chat-assistant pre:hover::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); }
|
||||
@media (hover: none) {
|
||||
.docs-chat-assistant pre { -webkit-overflow-scrolling: touch; }
|
||||
.docs-chat-assistant pre::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); }
|
||||
}
|
||||
.docs-chat-assistant pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
display: block;
|
||||
}
|
||||
/* Compact single-line code blocks */
|
||||
.docs-chat-assistant pre.compact {
|
||||
margin: 4px 0;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
/* Longer code blocks with copy button need extra top padding */
|
||||
.docs-chat-assistant pre:not(.compact) {
|
||||
padding-top: 28px;
|
||||
}
|
||||
.docs-chat-assistant a {
|
||||
color: var(--docs-chat-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.docs-chat-assistant a:hover { opacity: 0.8; }
|
||||
.docs-chat-assistant ul, .docs-chat-assistant ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 18px;
|
||||
list-style: none;
|
||||
}
|
||||
.docs-chat-assistant li {
|
||||
margin: 4px 0;
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
.docs-chat-assistant li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--docs-chat-muted);
|
||||
}
|
||||
.docs-chat-assistant strong { font-weight: 600; }
|
||||
.docs-chat-assistant em { font-style: italic; }
|
||||
.docs-chat-assistant h1, .docs-chat-assistant h2, .docs-chat-assistant h3 {
|
||||
font-weight: 600;
|
||||
margin: 12px 0 6px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.docs-chat-assistant h1 { font-size: 1.2em; }
|
||||
.docs-chat-assistant h2 { font-size: 1.1em; }
|
||||
.docs-chat-assistant h3 { font-size: 1.05em; }
|
||||
.docs-chat-assistant blockquote {
|
||||
border-left: 3px solid var(--docs-chat-accent);
|
||||
margin: 10px 0;
|
||||
padding: 4px 12px;
|
||||
color: var(--docs-chat-muted);
|
||||
background: var(--docs-chat-code-bg);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.docs-chat-assistant hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--docs-chat-panel-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
/* Copy buttons */
|
||||
.docs-chat-assistant { position: relative; padding-top: 28px; }
|
||||
.docs-chat-copy-response {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--docs-chat-surface);
|
||||
border: 1px solid var(--docs-chat-panel-border);
|
||||
border-radius: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
color: var(--docs-chat-muted);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.docs-chat-copy-response:hover {
|
||||
color: var(--docs-chat-text);
|
||||
background: var(--docs-chat-code-bg);
|
||||
}
|
||||
.docs-chat-assistant pre {
|
||||
position: relative;
|
||||
}
|
||||
.docs-chat-copy-code {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--docs-chat-surface);
|
||||
border: 1px solid var(--docs-chat-panel-border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--docs-chat-muted);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
.docs-chat-copy-code:hover {
|
||||
color: var(--docs-chat-text);
|
||||
background: var(--docs-chat-code-bg);
|
||||
}
|
||||
/* Resize handle - left edge of expanded panel */
|
||||
#docs-chat-resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
#docs-chat-root.docs-chat-expanded #docs-chat-resize-handle { display: block; }
|
||||
#docs-chat-resize-handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 40px;
|
||||
border-radius: 2px;
|
||||
background: var(--docs-chat-panel-border);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
#docs-chat-resize-handle:hover::after,
|
||||
#docs-chat-resize-handle.docs-chat-dragging::after {
|
||||
opacity: 1;
|
||||
background: var(--docs-chat-accent);
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
#docs-chat-resize-handle { display: none !important; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "docs-chat-root";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.id = "docs-chat-button";
|
||||
button.type = "button";
|
||||
button.innerHTML =
|
||||
`<img class="docs-chat-logo" src="${assetsBase}/assets/pixel-lobster.svg" alt="OpenClaw">` +
|
||||
`<span>Ask Molty</span>`;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "docs-chat-panel";
|
||||
panel.style.display = "none";
|
||||
|
||||
// Resize handle for expandable sidebar width (desktop only)
|
||||
const resizeHandle = document.createElement("div");
|
||||
resizeHandle.id = "docs-chat-resize-handle";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.id = "docs-chat-header";
|
||||
header.innerHTML =
|
||||
`<div id="docs-chat-header-title">` +
|
||||
`<img class="docs-chat-logo" src="${assetsBase}/assets/pixel-lobster.svg" alt="OpenClaw">` +
|
||||
`<span>OpenClaw Docs</span>` +
|
||||
`</div>` +
|
||||
`<div id="docs-chat-header-actions"></div>`;
|
||||
const headerActions = header.querySelector("#docs-chat-header-actions");
|
||||
const expand = document.createElement("button");
|
||||
expand.type = "button";
|
||||
expand.className = "docs-chat-icon-button";
|
||||
expand.setAttribute("aria-label", "Expand");
|
||||
expand.textContent = "⤢";
|
||||
const clear = document.createElement("button");
|
||||
clear.type = "button";
|
||||
clear.className = "docs-chat-icon-button";
|
||||
clear.setAttribute("aria-label", "Clear chat");
|
||||
clear.textContent = "⌫";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "docs-chat-icon-button";
|
||||
close.setAttribute("aria-label", "Close");
|
||||
close.textContent = "×";
|
||||
headerActions.appendChild(expand);
|
||||
headerActions.appendChild(clear);
|
||||
headerActions.appendChild(close);
|
||||
|
||||
const messages = document.createElement("div");
|
||||
messages.id = "docs-chat-messages";
|
||||
|
||||
const inputWrap = document.createElement("div");
|
||||
inputWrap.id = "docs-chat-input";
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.rows = 1;
|
||||
textarea.placeholder = "Ask about OpenClaw Docs...";
|
||||
|
||||
// Auto-expand textarea as user types (up to max-height set in CSS)
|
||||
const autoExpand = () => {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 224) + "px";
|
||||
};
|
||||
textarea.addEventListener("input", autoExpand);
|
||||
|
||||
const send = document.createElement("button");
|
||||
send.id = "docs-chat-send";
|
||||
send.type = "button";
|
||||
send.textContent = "Send";
|
||||
|
||||
inputWrap.appendChild(textarea);
|
||||
inputWrap.appendChild(send);
|
||||
|
||||
panel.appendChild(resizeHandle);
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(messages);
|
||||
panel.appendChild(inputWrap);
|
||||
|
||||
root.appendChild(button);
|
||||
root.appendChild(panel);
|
||||
document.body.appendChild(root);
|
||||
|
||||
// Add copy buttons to assistant bubble
|
||||
const addCopyButtons = (bubble, rawText) => {
|
||||
// Add copy response button
|
||||
const copyResponse = document.createElement("button");
|
||||
copyResponse.className = "docs-chat-copy-response";
|
||||
copyResponse.textContent = "Copy";
|
||||
copyResponse.type = "button";
|
||||
copyResponse.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawText);
|
||||
copyResponse.textContent = "Copied!";
|
||||
setTimeout(() => (copyResponse.textContent = "Copy"), 1500);
|
||||
} catch (e) {
|
||||
copyResponse.textContent = "Failed";
|
||||
}
|
||||
});
|
||||
bubble.appendChild(copyResponse);
|
||||
|
||||
// Add copy buttons to code blocks (skip short/single-line blocks)
|
||||
bubble.querySelectorAll("pre").forEach((pre) => {
|
||||
const code = pre.querySelector("code") || pre;
|
||||
const text = code.textContent || "";
|
||||
const lineCount = text.split("\n").length;
|
||||
const isShort = lineCount <= 2 && text.length < 100;
|
||||
|
||||
if (isShort) {
|
||||
pre.classList.add("compact");
|
||||
return; // Skip copy button for compact blocks
|
||||
}
|
||||
|
||||
const copyCode = document.createElement("button");
|
||||
copyCode.className = "docs-chat-copy-code";
|
||||
copyCode.textContent = "Copy";
|
||||
copyCode.type = "button";
|
||||
copyCode.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copyCode.textContent = "Copied!";
|
||||
setTimeout(() => (copyCode.textContent = "Copy"), 1500);
|
||||
} catch (err) {
|
||||
copyCode.textContent = "Failed";
|
||||
}
|
||||
});
|
||||
pre.appendChild(copyCode);
|
||||
});
|
||||
};
|
||||
|
||||
const addBubble = (text, role, isMarkdown = false) => {
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className =
|
||||
"docs-chat-bubble " +
|
||||
(role === "user" ? "docs-chat-user" : "docs-chat-assistant");
|
||||
if (isMarkdown && role === "assistant") {
|
||||
bubble.innerHTML = renderMarkdown(text);
|
||||
} else {
|
||||
bubble.textContent = text;
|
||||
}
|
||||
messages.appendChild(bubble);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
return bubble;
|
||||
};
|
||||
|
||||
let isExpanded = false;
|
||||
let customWidth = null; // User-set width via drag
|
||||
const MIN_WIDTH = 320;
|
||||
const MAX_WIDTH = 800;
|
||||
|
||||
// Drag-to-resize logic
|
||||
let isDragging = false;
|
||||
let startX, startWidth;
|
||||
|
||||
resizeHandle.addEventListener("mousedown", (e) => {
|
||||
if (!isExpanded) return;
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
resizeHandle.classList.add("docs-chat-dragging");
|
||||
document.body.style.cursor = "ew-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!isDragging) return;
|
||||
// Panel is on right, so dragging left increases width
|
||||
const delta = startX - e.clientX;
|
||||
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
|
||||
customWidth = newWidth;
|
||||
panel.style.width = newWidth + "px";
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
resizeHandle.classList.remove("docs-chat-dragging");
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
});
|
||||
|
||||
const setOpen = (isOpen) => {
|
||||
panel.style.display = isOpen ? "flex" : "none";
|
||||
button.style.display = isOpen ? "none" : "inline-flex";
|
||||
root.classList.toggle("docs-chat-expanded", isOpen && isExpanded);
|
||||
if (!isOpen) {
|
||||
panel.style.width = ""; // Reset to CSS default when closed
|
||||
} else if (isExpanded && customWidth) {
|
||||
panel.style.width = customWidth + "px";
|
||||
}
|
||||
if (isOpen) textarea.focus();
|
||||
};
|
||||
|
||||
const setExpanded = (next) => {
|
||||
isExpanded = next;
|
||||
expand.textContent = isExpanded ? "⤡" : "⤢";
|
||||
expand.setAttribute("aria-label", isExpanded ? "Collapse" : "Expand");
|
||||
if (panel.style.display !== "none") {
|
||||
root.classList.toggle("docs-chat-expanded", isExpanded);
|
||||
if (isExpanded && customWidth) {
|
||||
panel.style.width = customWidth + "px";
|
||||
} else if (!isExpanded) {
|
||||
panel.style.width = ""; // Reset to CSS default
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener("click", () => setOpen(true));
|
||||
expand.addEventListener("click", () => setExpanded(!isExpanded));
|
||||
clear.addEventListener("click", () => {
|
||||
messages.innerHTML = "";
|
||||
});
|
||||
close.addEventListener("click", () => {
|
||||
setOpen(false);
|
||||
root.classList.remove("docs-chat-expanded");
|
||||
});
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
textarea.value = "";
|
||||
textarea.style.height = "auto"; // Reset height after sending
|
||||
addBubble(text, "user");
|
||||
const assistantBubble = addBubble("...", "assistant");
|
||||
assistantBubble.innerHTML = "";
|
||||
|
||||
let fullText = "";
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text }),
|
||||
});
|
||||
|
||||
// Handle rate limiting
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("Retry-After") || "60";
|
||||
fullText = `You're asking questions too quickly. Please wait ${retryAfter} seconds before trying again.`;
|
||||
assistantBubble.innerHTML = renderMarkdown(fullText);
|
||||
addCopyButtons(assistantBubble, fullText);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
fullText = errorData.error || "Something went wrong. Please try again.";
|
||||
} catch {
|
||||
fullText = "Something went wrong. Please try again.";
|
||||
}
|
||||
assistantBubble.innerHTML = renderMarkdown(fullText);
|
||||
addCopyButtons(assistantBubble, fullText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
fullText = await response.text();
|
||||
assistantBubble.innerHTML = renderMarkdown(fullText);
|
||||
addCopyButtons(assistantBubble, fullText);
|
||||
return;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
fullText += decoder.decode(value, { stream: true });
|
||||
// Re-render markdown on each chunk for live preview
|
||||
assistantBubble.innerHTML = renderMarkdown(fullText);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
// Flush any remaining buffered bytes (partial UTF-8 sequences)
|
||||
fullText += decoder.decode();
|
||||
assistantBubble.innerHTML = renderMarkdown(fullText);
|
||||
// Add copy buttons after streaming completes
|
||||
addCopyButtons(assistantBubble, fullText);
|
||||
} catch (err) {
|
||||
fullText = "Failed to reach docs chat API.";
|
||||
assistantBubble.innerHTML = renderMarkdown(fullText);
|
||||
addCopyButtons(assistantBubble, fullText);
|
||||
}
|
||||
};
|
||||
|
||||
send.addEventListener("click", sendMessage);
|
||||
textarea.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -23,7 +23,7 @@ cron is the mechanism.
|
||||
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
|
||||
- Two execution styles:
|
||||
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
|
||||
## Quick start (actionable)
|
||||
@@ -53,7 +53,7 @@ openclaw cron add \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize overnight updates." \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel slack \
|
||||
--to "channel:C1234567890"
|
||||
```
|
||||
@@ -86,7 +86,8 @@ Think of a cron job as: **when** to run + **what** to do.
|
||||
- Main session → `payload.kind = "systemEvent"`
|
||||
- Isolated session → `payload.kind = "agentTurn"`
|
||||
|
||||
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
|
||||
Optional: one-shot jobs (`schedule.kind = "at"`) delete after success by default. Set
|
||||
`deleteAfterRun: false` to keep them (they will disable after success).
|
||||
|
||||
## Concepts
|
||||
|
||||
@@ -96,19 +97,19 @@ A cron job is a stored record with:
|
||||
|
||||
- a **schedule** (when it should run),
|
||||
- a **payload** (what it should do),
|
||||
- optional **delivery** (where output should be sent).
|
||||
- optional **delivery mode** (announce or none).
|
||||
- optional **agent binding** (`agentId`): run the job under a specific agent; if
|
||||
missing or unknown, the gateway falls back to the default agent.
|
||||
|
||||
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
||||
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
||||
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
|
||||
One-shot jobs auto-delete after success by default; set `deleteAfterRun: false` to keep them.
|
||||
|
||||
### Schedules
|
||||
|
||||
Cron supports three schedule kinds:
|
||||
|
||||
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
|
||||
- `at`: one-shot timestamp via `schedule.at` (ISO 8601).
|
||||
- `every`: fixed interval (ms).
|
||||
- `cron`: 5-field cron expression with optional IANA timezone.
|
||||
|
||||
@@ -136,9 +137,13 @@ Key behaviors:
|
||||
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
||||
- A summary is posted to the main session (prefix `Cron`, configurable).
|
||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
|
||||
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
|
||||
- `delivery.mode` (isolated-only) chooses what happens:
|
||||
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
|
||||
- `none`: internal only (no delivery, no main-session summary).
|
||||
- `wakeMode` controls when the main-session summary posts:
|
||||
- `now`: immediate heartbeat.
|
||||
- `next-heartbeat`: waits for the next scheduled heartbeat.
|
||||
|
||||
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
|
||||
your main chat history.
|
||||
@@ -155,16 +160,35 @@ Common `agentTurn` fields:
|
||||
- `message`: required text prompt.
|
||||
- `model` / `thinking`: optional overrides (see below).
|
||||
- `timeoutSeconds`: optional timeout override.
|
||||
- `deliver`: `true` to send output to a channel target.
|
||||
- `channel`: `last` or a specific channel.
|
||||
- `to`: channel-specific target (phone/chat/channel id).
|
||||
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
||||
|
||||
Isolation options (only for `session=isolated`):
|
||||
Delivery config (isolated jobs only):
|
||||
|
||||
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the system event in main.
|
||||
- `postToMainMode`: `summary` (default) or `full`.
|
||||
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
|
||||
- `delivery.mode`: `none` | `announce`.
|
||||
- `delivery.channel`: `last` or a specific channel.
|
||||
- `delivery.to`: channel-specific target (phone/chat/channel id).
|
||||
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
|
||||
|
||||
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
|
||||
to target the chat instead. When `delivery.mode = "none"`, no summary is posted to the main session.
|
||||
|
||||
If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce`.
|
||||
|
||||
#### Announce delivery flow
|
||||
|
||||
When `delivery.mode = "announce"`, cron delivers directly via the outbound channel adapters.
|
||||
The main agent is not spun up to craft or forward the message.
|
||||
|
||||
Behavior details:
|
||||
|
||||
- Content: delivery uses the isolated run's outbound payloads (text/media) with normal chunking and
|
||||
channel formatting.
|
||||
- Heartbeat-only responses (`HEARTBEAT_OK` with no real content) are not delivered.
|
||||
- If the isolated run already sent a message to the same target via the message tool, delivery is
|
||||
skipped to avoid duplicates.
|
||||
- Missing or invalid delivery targets fail the job unless `delivery.bestEffort = true`.
|
||||
- A short summary is posted to the main session only when `delivery.mode = "announce"`.
|
||||
- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and
|
||||
`next-heartbeat` waits for the next scheduled heartbeat.
|
||||
|
||||
### Model and thinking overrides
|
||||
|
||||
@@ -185,19 +209,16 @@ Resolution priority:
|
||||
|
||||
### Delivery (channel + target)
|
||||
|
||||
Isolated jobs can deliver output to a channel. The job payload can specify:
|
||||
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
|
||||
|
||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
|
||||
- `to`: channel-specific recipient target
|
||||
- `delivery.mode`: `announce` (deliver a summary) or `none`.
|
||||
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
|
||||
- `delivery.to`: channel-specific recipient target.
|
||||
|
||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||
(the last place the agent replied).
|
||||
Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
|
||||
|
||||
Delivery notes:
|
||||
|
||||
- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted.
|
||||
- Use `deliver: true` when you want last-route delivery without an explicit `to`.
|
||||
- Use `deliver: false` to keep output internal even if a `to` is present.
|
||||
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s
|
||||
“last route” (the last place the agent replied).
|
||||
|
||||
Target format reminders:
|
||||
|
||||
@@ -220,8 +241,8 @@ Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted:
|
||||
## JSON schema for tool calls
|
||||
|
||||
Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC).
|
||||
CLI flags accept human durations like `20m`, but tool calls use epoch milliseconds for
|
||||
`atMs` and `everyMs` (ISO timestamps are accepted for `at` times).
|
||||
CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string
|
||||
for `schedule.at` and milliseconds for `schedule.everyMs`.
|
||||
|
||||
### cron.add params
|
||||
|
||||
@@ -230,7 +251,7 @@ One-shot, main session job (system event):
|
||||
```json
|
||||
{
|
||||
"name": "Reminder",
|
||||
"schedule": { "kind": "at", "atMs": 1738262400000 },
|
||||
"schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" },
|
||||
"sessionTarget": "main",
|
||||
"wakeMode": "now",
|
||||
"payload": { "kind": "systemEvent", "text": "Reminder text" },
|
||||
@@ -248,22 +269,25 @@ Recurring, isolated job with delivery:
|
||||
"wakeMode": "next-heartbeat",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "Summarize overnight updates.",
|
||||
"deliver": true,
|
||||
"message": "Summarize overnight updates."
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "announce",
|
||||
"channel": "slack",
|
||||
"to": "channel:C1234567890",
|
||||
"bestEffortDeliver": true
|
||||
},
|
||||
"isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }
|
||||
"bestEffort": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
|
||||
- `atMs` and `everyMs` are epoch milliseconds.
|
||||
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
|
||||
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
|
||||
- `everyMs` is milliseconds.
|
||||
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `isolation`.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
|
||||
`delivery`.
|
||||
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
|
||||
|
||||
### cron.update params
|
||||
@@ -341,7 +365,7 @@ openclaw cron add \
|
||||
--wake now
|
||||
```
|
||||
|
||||
Recurring isolated job (deliver to WhatsApp):
|
||||
Recurring isolated job (announce to WhatsApp):
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
@@ -350,7 +374,7 @@ openclaw cron add \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize inbox + calendar for today." \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
@@ -364,7 +388,7 @@ openclaw cron add \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize today; send to the nightly topic." \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
--to "-1001234567890:topic:123"
|
||||
```
|
||||
@@ -380,7 +404,7 @@ openclaw cron add \
|
||||
--message "Weekly deep analysis of project progress." \
|
||||
--model "opus" \
|
||||
--thinking high \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
@@ -90,7 +90,8 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect
|
||||
- **Exact timing**: 5-field cron expressions with timezone support.
|
||||
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
||||
- **Model overrides**: Use a cheaper or more powerful model per job.
|
||||
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
|
||||
- **Delivery control**: Isolated jobs default to `announce` (summary); choose `none` as needed.
|
||||
- **Immediate delivery**: Announce mode posts directly without waiting for heartbeat.
|
||||
- **No agent context needed**: Runs even if main session is idle or compacted.
|
||||
- **One-shot support**: `--at` for precise future timestamps.
|
||||
|
||||
@@ -104,12 +105,12 @@ openclaw cron add \
|
||||
--session isolated \
|
||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||
--model opus \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
|
||||
This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces a summary directly to WhatsApp.
|
||||
|
||||
### Cron example: One-shot reminder
|
||||
|
||||
@@ -173,7 +174,7 @@ The most efficient setup uses **both**:
|
||||
|
||||
```bash
|
||||
# Daily morning briefing at 7am
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
|
||||
|
||||
# Weekly project review on Mondays at 9am
|
||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||
@@ -214,13 +215,13 @@ See [Lobster](/tools/lobster) for full usage and examples.
|
||||
|
||||
Both heartbeat and cron can interact with the main session, but differently:
|
||||
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
| ------- | ------------------------------- | ------------------------ | ---------------------- |
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
||||
| History | Shared | Shared | Fresh each run |
|
||||
| Context | Full | Full | None (starts clean) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
| ------- | ------------------------------- | ------------------------ | -------------------------- |
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
||||
| History | Shared | Shared | Fresh each run |
|
||||
| Context | Full | Full | None (starts clean) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
|
||||
|
||||
### When to use main session cron
|
||||
|
||||
@@ -245,7 +246,7 @@ Use `--session isolated` when you want:
|
||||
|
||||
- A clean slate without prior context
|
||||
- Different model or thinking settings
|
||||
- Output delivered directly to a channel (summary still posts to main by default)
|
||||
- Announce summaries directly to a channel
|
||||
- History that doesn't clutter main session
|
||||
|
||||
```bash
|
||||
@@ -256,7 +257,7 @@ openclaw cron add \
|
||||
--message "Weekly codebase analysis..." \
|
||||
--model opus \
|
||||
--thinking high \
|
||||
--deliver
|
||||
--announce
|
||||
```
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
@@ -42,6 +42,80 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
|
||||
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
|
||||
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||
|
||||
## Keeping Messages.app alive (VM / headless setups)
|
||||
|
||||
Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
|
||||
|
||||
### 1) Save the AppleScript
|
||||
|
||||
Save this as:
|
||||
|
||||
- `~/Scripts/poke-messages.scpt`
|
||||
|
||||
Example script (non-interactive; does not steal focus):
|
||||
|
||||
```applescript
|
||||
try
|
||||
tell application "Messages"
|
||||
if not running then
|
||||
launch
|
||||
end if
|
||||
|
||||
-- Touch the scripting interface to keep the process responsive.
|
||||
set _chatCount to (count of chats)
|
||||
end tell
|
||||
on error
|
||||
-- Ignore transient failures (first-run prompts, locked session, etc).
|
||||
end try
|
||||
```
|
||||
|
||||
### 2) Install a LaunchAgent
|
||||
|
||||
Save this as:
|
||||
|
||||
- `~/Library/LaunchAgents/com.user.poke-messages.plist`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.user.poke-messages</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>-lc</string>
|
||||
<string>/usr/bin/osascript "$HOME/Scripts/poke-messages.scpt"</string>
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/poke-messages.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/poke-messages.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This runs **every 300 seconds** and **on login**.
|
||||
- The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent.
|
||||
|
||||
Load it:
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true
|
||||
launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist
|
||||
```
|
||||
|
||||
## Onboarding
|
||||
|
||||
BlueBubbles is available in the interactive setup wizard:
|
||||
|
||||
@@ -100,7 +100,7 @@ In **Bot** → **Privileged Gateway Intents**, enable:
|
||||
- **Message Content Intent** (required to read message text in most guilds; without it you’ll see “Used disallowed intents” or the bot will connect but not react to messages)
|
||||
- **Server Members Intent** (recommended; required for some member/user lookups and allowlist matching in guilds)
|
||||
|
||||
You usually do **not** need **Presence Intent**.
|
||||
You usually do **not** need **Presence Intent**. Setting the bot's own presence (`setPresence` action) uses gateway OP3 and does not require this intent; it is only needed if you want to receive presence updates about other guild members.
|
||||
|
||||
### 3) Generate an invite URL (OAuth2 URL Generator)
|
||||
|
||||
@@ -278,6 +278,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
moderation: false,
|
||||
presence: false,
|
||||
},
|
||||
replyToMode: "off",
|
||||
dm: {
|
||||
@@ -353,6 +354,7 @@ ack reaction after the bot replies.
|
||||
- `channels` (create/edit/delete channels + categories + permissions)
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
- `presence` (bot status/activity, default `false`)
|
||||
- `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`.
|
||||
|
||||
Reaction notifications use `guilds.<id>.reactionNotifications`:
|
||||
@@ -412,6 +414,7 @@ Allowlist notes (PK-enabled):
|
||||
| events | enabled | List/create scheduled events |
|
||||
| roles | disabled | Role add/remove |
|
||||
| moderation | disabled | Timeout/kick/ban |
|
||||
| presence | disabled | Bot status/activity (setPresence) |
|
||||
|
||||
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
|
||||
|
||||
@@ -460,6 +463,7 @@ The agent can call `discord` with actions like:
|
||||
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
|
||||
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
|
||||
- `timeout`, `kick`, `ban`
|
||||
- `setPresence` (bot activity and online status)
|
||||
|
||||
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
|
||||
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Feishu bot support status, features, and configuration"
|
||||
summary: "Feishu bot overview, features, and configuration"
|
||||
read_when:
|
||||
- You want to connect a Feishu/Lark bot
|
||||
- You are configuring the Feishu channel
|
||||
@@ -8,7 +8,7 @@ title: Feishu
|
||||
|
||||
# Feishu bot
|
||||
|
||||
Status: production-ready, supports bot DMs and group chats. Uses WebSocket long connection mode to receive events.
|
||||
Feishu (Lark) is a team chat platform used by companies for messaging and collaboration. This plugin connects OpenClaw to a Feishu/Lark bot using the platform’s WebSocket event subscription so messages can be received without exposing a public webhook URL.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
---
|
||||
summary: "iMessage support via imsg (JSON-RPC over stdio), setup, and chat_id routing"
|
||||
summary: "Legacy iMessage support via imsg (JSON-RPC over stdio). New setups should use BlueBubbles."
|
||||
read_when:
|
||||
- Setting up iMessage support
|
||||
- Debugging iMessage send/receive
|
||||
title: iMessage
|
||||
---
|
||||
|
||||
# iMessage (imsg)
|
||||
# iMessage (legacy: imsg)
|
||||
|
||||
Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio).
|
||||
> **Recommended:** Use [BlueBubbles](/channels/bluebubbles) for new iMessage setups.
|
||||
>
|
||||
> The `imsg` channel is a legacy external-CLI integration and may be removed in a future release.
|
||||
|
||||
Status: legacy external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio).
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||
- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups).
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately).
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||
|
||||
@@ -72,6 +72,7 @@ Minimal config:
|
||||
- `openclaw pairing list nextcloud-talk`
|
||||
- `openclaw pairing approve nextcloud-talk <CODE>`
|
||||
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
|
||||
- `allowFrom` matches Nextcloud user IDs only; display names are ignored.
|
||||
|
||||
## Rooms (groups)
|
||||
|
||||
|
||||
@@ -16,12 +16,17 @@ Related:
|
||||
|
||||
Tip: run `openclaw cron --help` for the full command surface.
|
||||
|
||||
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep
|
||||
output internal. `--deliver` remains as a deprecated alias for `--announce`.
|
||||
|
||||
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
|
||||
|
||||
## Common edits
|
||||
|
||||
Update delivery settings without changing the message:
|
||||
|
||||
```bash
|
||||
openclaw cron edit <job-id> --deliver --channel telegram --to "123456789"
|
||||
openclaw cron edit <job-id> --announce --channel telegram --to "123456789"
|
||||
```
|
||||
|
||||
Disable delivery for an isolated job:
|
||||
@@ -29,3 +34,9 @@ Disable delivery for an isolated job:
|
||||
```bash
|
||||
openclaw cron edit <job-id> --no-deliver
|
||||
```
|
||||
|
||||
Announce to a specific channel:
|
||||
|
||||
```bash
|
||||
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
|
||||
```
|
||||
|
||||
@@ -303,7 +303,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
|
||||
@@ -22,5 +22,5 @@ openclaw security audit --deep
|
||||
openclaw security audit --fix
|
||||
```
|
||||
|
||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
|
||||
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
|
||||
@@ -17,6 +17,26 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
||||
- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
|
||||
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
|
||||
|
||||
### Secure DM mode (recommended)
|
||||
|
||||
If your agent can receive DMs from **multiple people** (pairing approvals for more than one sender, a DM allowlist with multiple entries, or `dmPolicy: "open"`), enable **secure DM mode** to avoid cross-user context leakage:
|
||||
|
||||
```json5
|
||||
// ~/.openclaw/openclaw.json
|
||||
{
|
||||
session: {
|
||||
// Secure DM mode: isolate DM context per channel + sender.
|
||||
dmScope: "per-channel-peer",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Default is `dmScope: "main"` for continuity (all DMs share the main session).
|
||||
- For multi-account inboxes on the same channel, prefer `per-account-channel-peer`.
|
||||
- If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity.
|
||||
|
||||
## Gateway is the source of truth
|
||||
|
||||
All session state is **owned by the gateway** (the “master” OpenClaw). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
||||
|
||||
@@ -446,6 +446,32 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
}
|
||||
```
|
||||
|
||||
### Secure DM mode (shared inbox / multi-user DMs)
|
||||
|
||||
If more than one person can DM your bot (multiple entries in `allowFrom`, pairing approvals for multiple people, or `dmPolicy: "open"`), enable **secure DM mode** so DMs from different senders don’t share one context by default:
|
||||
|
||||
```json5
|
||||
{
|
||||
// Secure DM mode (recommended for multi-user or sensitive DM agents)
|
||||
session: { dmScope: "per-channel-peer" },
|
||||
|
||||
channels: {
|
||||
// Example: WhatsApp multi-user inbox
|
||||
whatsapp: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15555550123", "+15555550124"],
|
||||
},
|
||||
|
||||
// Example: Discord multi-user inbox
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "YOUR_DISCORD_BOT_TOKEN",
|
||||
dm: { enabled: true, allowFrom: ["alice", "bob"] },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth with API key failover
|
||||
|
||||
```json5
|
||||
|
||||
@@ -2039,6 +2039,7 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
- `tools.web.search.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.enabled` (default true)
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.maxCharsCap` (default 50000; clamps maxChars from config/tool calls)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
@@ -2551,7 +2552,9 @@ Notes:
|
||||
|
||||
- Set `MOONSHOT_API_KEY` in the environment or use `openclaw onboard --auth-choice moonshot-api-key`.
|
||||
- Model ref: `moonshot/kimi-k2.5`.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
- For the China endpoint, either:
|
||||
- Run `openclaw onboard --auth-choice moonshot-api-key-cn` (wizard will set `https://api.moonshot.cn/v1`), or
|
||||
- Manually set `baseUrl: "https://api.moonshot.cn/v1"` in `models.providers.moonshot`.
|
||||
|
||||
### Kimi Coding
|
||||
|
||||
@@ -2763,6 +2766,7 @@ Fields:
|
||||
- `per-peer`: isolate DMs by sender id across channels.
|
||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
||||
- `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes).
|
||||
- Secure DM mode (recommended): set `session.dmScope: "per-channel-peer"` when multiple people can DM the bot (shared inboxes, multi-person allowlists, or `dmPolicy: "open"`).
|
||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
|
||||
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
||||
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
|
||||
|
||||
@@ -205,7 +205,16 @@ By default, OpenClaw routes **all DMs into the main session** so your assistant
|
||||
}
|
||||
```
|
||||
|
||||
This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
This prevents cross-user context leakage while keeping group chats isolated.
|
||||
|
||||
### Secure DM mode (recommended)
|
||||
|
||||
Treat the snippet above as **secure DM mode**:
|
||||
|
||||
- Default: `session.dmScope: "main"` (all DMs share one session for continuity).
|
||||
- Secure DM mode: `session.dmScope: "per-channel-peer"` (each channel+sender pair gets an isolated DM context).
|
||||
|
||||
If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
|
||||
## Allowlists (DM + groups) — terminology
|
||||
|
||||
|
||||
364
docs/help/faq.md
364
docs/help/faq.md
File diff suppressed because it is too large
Load Diff
@@ -210,7 +210,8 @@ Example:
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost (plugin)](/channels/mattermost)
|
||||
- [iMessage](/channels/imessage)
|
||||
- [BlueBubbles (iMessage)](/channels/bluebubbles)
|
||||
- [iMessage (legacy)](/channels/imessage)
|
||||
- [Groups](/concepts/groups)
|
||||
- [WhatsApp group messages](/concepts/group-messages)
|
||||
- [Media: images](/nodes/images)
|
||||
|
||||
@@ -446,7 +446,10 @@ Example voice-call config with ngrok:
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"provider": "twilio",
|
||||
"tunnel": { "provider": "ngrok" }
|
||||
"tunnel": { "provider": "ngrok" },
|
||||
"webhookSecurity": {
|
||||
"allowedHosts": ["example.ngrok.app"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,7 +457,7 @@ Example voice-call config with ngrok:
|
||||
}
|
||||
```
|
||||
|
||||
The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself.
|
||||
The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. Set `webhookSecurity.allowedHosts` to the public tunnel hostname so forwarded host headers are accepted.
|
||||
|
||||
### Security benefits
|
||||
|
||||
|
||||
@@ -34,17 +34,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.2.2 \
|
||||
APP_VERSION=2026.2.3 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.2.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.3.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.2.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.3.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.2.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.2.2 \
|
||||
APP_VERSION=2026.2.3 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.2.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.3.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.2.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.3.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.2.2.zip` (and `OpenClaw-2026.2.2.dSYM.zip`) to the GitHub release for tag `v2026.2.2`.
|
||||
- Upload `OpenClaw-2026.2.3.zip` (and `OpenClaw-2026.2.3.dSYM.zip`) to the GitHub release for tag `v2026.2.3`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -81,6 +81,12 @@ Set config under `plugins.entries.voice-call.config`:
|
||||
path: "/voice/webhook",
|
||||
},
|
||||
|
||||
// Webhook security (recommended for tunnels/proxies)
|
||||
webhookSecurity: {
|
||||
allowedHosts: ["voice.example.com"],
|
||||
trustedProxyIPs: ["100.64.0.1"],
|
||||
},
|
||||
|
||||
// Public exposure (pick one)
|
||||
// publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
// tunnel: { provider: "ngrok" },
|
||||
@@ -111,6 +117,38 @@ Notes:
|
||||
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
|
||||
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
|
||||
|
||||
## Webhook Security
|
||||
|
||||
When a proxy or tunnel sits in front of the Gateway, the plugin reconstructs the
|
||||
public URL for signature verification. These options control which forwarded
|
||||
headers are trusted.
|
||||
|
||||
`webhookSecurity.allowedHosts` allowlists hosts from forwarding headers.
|
||||
|
||||
`webhookSecurity.trustForwardingHeaders` trusts forwarded headers without an allowlist.
|
||||
|
||||
`webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request
|
||||
remote IP matches the list.
|
||||
|
||||
Example with a stable public host:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
publicUrl: "https://voice.example.com/voice/webhook",
|
||||
webhookSecurity: {
|
||||
allowedHosts: ["voice.example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## TTS for calls
|
||||
|
||||
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
||||
|
||||
@@ -106,6 +106,7 @@ Current npm plugin list (update as needed):
|
||||
- @openclaw/bluebubbles
|
||||
- @openclaw/diagnostics-otel
|
||||
- @openclaw/discord
|
||||
- @openclaw/feishu
|
||||
- @openclaw/lobster
|
||||
- @openclaw/matrix
|
||||
- @openclaw/msteams
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "RPC adapters for external CLIs (signal-cli, imsg) and gateway patterns"
|
||||
summary: "RPC adapters for external CLIs (signal-cli, legacy imsg) and gateway patterns"
|
||||
read_when:
|
||||
- Adding or changing external CLI integrations
|
||||
- Debugging RPC adapters (signal-cli, imsg)
|
||||
@@ -19,9 +19,11 @@ OpenClaw integrates external CLIs via JSON-RPC. Two patterns are used today.
|
||||
|
||||
See [Signal](/channels/signal) for setup and endpoints.
|
||||
|
||||
## Pattern B: stdio child process (imsg)
|
||||
## Pattern B: stdio child process (legacy: imsg)
|
||||
|
||||
- OpenClaw spawns `imsg rpc` as a child process.
|
||||
> **Note:** For new iMessage setups, use [BlueBubbles](/channels/bluebubbles) instead.
|
||||
|
||||
- OpenClaw spawns `imsg rpc` as a child process (legacy iMessage integration).
|
||||
- JSON-RPC is line-delimited over stdin/stdout (one JSON object per line).
|
||||
- No TCP port, no daemon required.
|
||||
|
||||
@@ -32,7 +34,7 @@ Core methods used:
|
||||
- `send`
|
||||
- `chats.list` (probe/diagnostics)
|
||||
|
||||
See [iMessage](/channels/imessage) for setup and addressing (`chat_id` preferred).
|
||||
See [iMessage](/channels/imessage) for legacy setup and addressing (`chat_id` preferred).
|
||||
|
||||
## Adapter guidelines
|
||||
|
||||
|
||||
@@ -42,13 +42,6 @@ Then open `SOUL.md` together and talk about:
|
||||
|
||||
Write it down. Make it real.
|
||||
|
||||
## One-time system admin check
|
||||
|
||||
Since this is a new install, offer a choice:
|
||||
|
||||
1. Run the recommended host healthcheck using the `healthcheck` skill.
|
||||
2. Skip for now (run later by saying “run healthcheck”).
|
||||
|
||||
## Connect (Optional)
|
||||
|
||||
Ask how they want to reach you:
|
||||
|
||||
@@ -71,7 +71,8 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost](/channels/mattermost) (plugin)
|
||||
- [Signal](/channels/signal)
|
||||
- [iMessage](/channels/imessage)
|
||||
- [BlueBubbles (iMessage)](/channels/bluebubbles)
|
||||
- [iMessage (legacy)](/channels/imessage)
|
||||
- [Location parsing](/channels/location)
|
||||
- [WebChat](/web/webchat)
|
||||
- [Webhooks](/automation/webhook)
|
||||
|
||||
@@ -80,6 +80,7 @@ Stored under `~/.openclaw/devices/`:
|
||||
- Telegram: [Telegram](/channels/telegram)
|
||||
- WhatsApp: [WhatsApp](/channels/whatsapp)
|
||||
- Signal: [Signal](/channels/signal)
|
||||
- iMessage: [iMessage](/channels/imessage)
|
||||
- BlueBubbles (iMessage): [BlueBubbles](/channels/bluebubbles)
|
||||
- iMessage (legacy): [iMessage](/channels/imessage)
|
||||
- Discord: [Discord](/channels/discord)
|
||||
- Slack: [Slack](/channels/slack)
|
||||
|
||||
@@ -127,7 +127,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- [Google Chat](/channels/googlechat): service account JSON + webhook audience.
|
||||
- [Mattermost](/channels/mattermost) (plugin): bot token + base URL.
|
||||
- [Signal](/channels/signal): optional `signal-cli` install + account config.
|
||||
- [iMessage](/channels/imessage): local `imsg` CLI path + DB access.
|
||||
- [BlueBubbles](/channels/bluebubbles): **recommended for iMessage**; server URL + password + webhook.
|
||||
- [iMessage](/channels/imessage): legacy `imsg` CLI path + DB access.
|
||||
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
|
||||
|
||||
6. **Daemon install**
|
||||
@@ -329,5 +330,5 @@ will prompt to install it (npm or a local path) before it can be configured.
|
||||
|
||||
- macOS app onboarding: [Onboarding](/start/onboarding)
|
||||
- Config reference: [Gateway configuration](/gateway/configuration)
|
||||
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [iMessage](/channels/imessage)
|
||||
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [BlueBubbles](/channels/bluebubbles) (iMessage), [iMessage](/channels/imessage) (legacy)
|
||||
- Skills: [Skills](/tools/skills), [Skills config](/tools/skills-config)
|
||||
|
||||
@@ -252,6 +252,7 @@ Core parameters:
|
||||
Notes:
|
||||
|
||||
- Enable via `tools.web.fetch.enabled`.
|
||||
- `maxChars` is clamped by `tools.web.fetch.maxCharsCap` (default 50000).
|
||||
- Responses are cached (default 15 min).
|
||||
- For JS-heavy sites, prefer the browser tool.
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
|
||||
@@ -221,6 +221,7 @@ Fetch a URL and extract readable content.
|
||||
fetch: {
|
||||
enabled: true,
|
||||
maxChars: 50000,
|
||||
maxCharsCap: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
maxRedirects: 3,
|
||||
@@ -252,6 +253,7 @@ Notes:
|
||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
|
||||
@@ -79,6 +79,11 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||
- Update: run a package/git update + restart (`update.run`) with a restart report
|
||||
|
||||
Cron jobs panel notes:
|
||||
|
||||
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
|
||||
- Channel/target fields appear when announce is selected.
|
||||
|
||||
## Chat behavior
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
---
|
||||
read_when:
|
||||
- 调度后台任务或唤醒
|
||||
- 配置需要与心跳一起运行或配合运行的自动化任务
|
||||
- 决定计划任务使用心跳还是定时任务
|
||||
summary: Gateway 网关调度器的定时任务与唤醒机制
|
||||
- 配置需要与心跳一起或并行运行的自动化
|
||||
- 在心跳和定时任务之间做选择
|
||||
summary: Gateway网关调度器的定时任务与唤醒
|
||||
title: 定时任务
|
||||
x-i18n:
|
||||
generated_at: "2026-02-03T07:44:30Z"
|
||||
generated_at: "2026-02-01T19:37:32Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: d43268b0029f1b13d0825ddcc9c06a354987ea17ce02f3b5428a9c68bf936676
|
||||
source_path: automation/cron-jobs.md
|
||||
workflow: 15
|
||||
workflow: 14
|
||||
---
|
||||
|
||||
# 定时任务(Gateway 网关调度器)
|
||||
# 定时任务(Gateway网关调度器)
|
||||
|
||||
> **定时任务还是心跳?** 请参阅[定时任务与心跳对比](/automation/cron-vs-heartbeat)了解何时使用哪种方式。
|
||||
|
||||
定时任务是 Gateway 网关内置的调度器。它持久化任务,在正确的时间唤醒智能体,并可选择将输出发送回聊天。
|
||||
定时任务是 Gateway网关内置的调度器。它持久化任务、在合适的时间唤醒智能体,并可选择将输出发送回聊天。
|
||||
|
||||
如果你需要"每天早上运行这个"或"20 分钟后触发智能体",定时任务就是实现机制。
|
||||
如果你想要 _"每天早上运行"_ 或 _"20 分钟后提醒智能体"_,定时任务就是对应的机制。
|
||||
|
||||
## 简要概述
|
||||
|
||||
- 定时任务运行在 **Gateway 网关内部**(不是在模型内部)。
|
||||
- 定时任务运行在 **Gateway网关内部**(而非模型内部)。
|
||||
- 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。
|
||||
- 两种执行方式:
|
||||
- **主会话**:将系统事件加入队列,然后在下一次心跳时运行。
|
||||
- **隔离**:在 `cron:<jobId>` 中运行专用的智能体回合,可选择发送输出。
|
||||
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳"。
|
||||
- **主会话**:入队一个系统事件,然后在下一次心跳时运行。
|
||||
- **隔离式**:在 `cron:<jobId>` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。
|
||||
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。
|
||||
|
||||
## 快速开始(可操作)
|
||||
|
||||
创建一个一次性提醒,验证它是否存在,然后立即运行:
|
||||
创建一个一次性提醒,验证其存在,然后立即运行:
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
@@ -49,7 +49,7 @@ openclaw cron run <job-id> --force
|
||||
openclaw cron runs --id <job-id>
|
||||
```
|
||||
|
||||
调度一个带消息发送的循环隔离任务:
|
||||
调度一个带投递功能的周期性隔离任务:
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
@@ -58,168 +58,158 @@ openclaw cron add \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize overnight updates." \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel slack \
|
||||
--to "channel:C1234567890"
|
||||
```
|
||||
|
||||
## 工具调用等效项(Gateway 网关定时任务工具)
|
||||
## 工具调用等价形式(Gateway网关定时任务工具)
|
||||
|
||||
有关规范的 JSON 结构和示例,请参阅[工具调用的 JSON schema](/automation/cron-jobs#json-schema-for-tool-calls)。
|
||||
有关规范的 JSON 结构和示例,请参阅[工具调用的 JSON 模式](/automation/cron-jobs#json-schema-for-tool-calls)。
|
||||
|
||||
## 定时任务的存储位置
|
||||
|
||||
定时任务默认持久化存储在 Gateway 网关主机的 `~/.openclaw/cron/jobs.json`。Gateway 网关将文件加载到内存中,并在更改时写回,因此只有在 Gateway 网关停止时手动编辑才是安全的。建议使用 `openclaw cron add/edit` 或定时任务工具调用 API 进行更改。
|
||||
定时任务默认持久化存储在 Gateway网关主机的 `~/.openclaw/cron/jobs.json` 中。Gateway网关将文件加载到内存中,并在更改时写回,因此仅在 Gateway网关停止时手动编辑才是安全的。请优先使用 `openclaw cron add/edit` 或定时任务工具调用 API 进行更改。
|
||||
|
||||
## 新手友好概述
|
||||
|
||||
将定时任务理解为:**何时**运行 + **做什么**。
|
||||
|
||||
1. **选择计划**
|
||||
1. **选择调度计划**
|
||||
- 一次性提醒 → `schedule.kind = "at"`(CLI:`--at`)
|
||||
- 重复任务 → `schedule.kind = "every"` 或 `schedule.kind = "cron"`
|
||||
- 如果你的 ISO 时间戳省略了时区,它将被视为 **UTC**。
|
||||
- 如果你的 ISO 时间戳省略了时区,将被视为 **UTC**。
|
||||
|
||||
2. **选择运行位置**
|
||||
- `sessionTarget: "main"` → 在下一次心跳时使用主上下文运行。
|
||||
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用的智能体回合。
|
||||
- `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。
|
||||
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体轮次。
|
||||
|
||||
3. **选择负载**
|
||||
- 主会话 → `payload.kind = "systemEvent"`
|
||||
- 隔离会话 → `payload.kind = "agentTurn"`
|
||||
|
||||
可选:`deleteAfterRun: true` 会在成功执行后从存储中删除一次性任务。
|
||||
可选:一次性任务(`schedule.kind = "at"`)默认会在成功运行后删除。设置
|
||||
`deleteAfterRun: false` 可保留它(成功后会禁用)。
|
||||
|
||||
## 概念
|
||||
|
||||
### 任务
|
||||
|
||||
定时任务是一个存储的记录,包含:
|
||||
定时任务是一条存储记录,包含:
|
||||
|
||||
- 一个**计划**(何时运行),
|
||||
- 一个**调度计划**(何时运行),
|
||||
- 一个**负载**(做什么),
|
||||
- 可选的**发送**(输出发送到哪里)。
|
||||
- 可选的**智能体绑定**(`agentId`):在特定智能体下运行任务;如果缺失或未知,Gateway 网关会回退到默认智能体。
|
||||
- 可选的**投递**(输出发送到哪里)。
|
||||
- 可选的**智能体绑定**(`agentId`):在指定智能体下运行任务;如果缺失或未知,Gateway网关会回退到默认智能体。
|
||||
|
||||
任务通过稳定的 `jobId` 标识(供 CLI/Gateway 网关 API 使用)。在智能体工具调用中,`jobId` 是规范名称;为了兼容性也接受旧版的 `id`。任务可以通过 `deleteAfterRun: true` 选择在一次性成功运行后自动删除。
|
||||
任务通过稳定的 `jobId` 标识(用于 CLI/Gateway网关 API)。
|
||||
在智能体工具调用中,`jobId` 是规范字段;旧版 `id` 仍可兼容使用。
|
||||
一次性任务默认会在成功运行后自动删除;设置 `deleteAfterRun: false` 可保留它。
|
||||
|
||||
### 计划
|
||||
### 调度计划
|
||||
|
||||
定时任务支持三种计划类型:
|
||||
定时任务支持三种调度类型:
|
||||
|
||||
- `at`:一次性时间戳(自纪元以来的毫秒数)。Gateway 网关接受 ISO 8601 并转换为 UTC。
|
||||
- `at`:一次性时间戳(ISO 8601 字符串)。
|
||||
- `every`:固定间隔(毫秒)。
|
||||
- `cron`:5 字段 cron 表达式,带可选的 IANA 时区。
|
||||
- `cron`:5 字段 cron 表达式,可选 IANA 时区。
|
||||
|
||||
Cron 表达式使用 `croner`。如果省略时区,则使用 Gateway 网关主机的本地时区。
|
||||
Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主机的本地时区。
|
||||
|
||||
### 主会话与隔离执行
|
||||
### 主会话与隔离式执行
|
||||
|
||||
#### 主会话任务(系统事件)
|
||||
|
||||
主任务将系统事件加入队列并可选择唤醒心跳运行器。它们必须使用 `payload.kind = "systemEvent"`。
|
||||
主会话任务入队一个系统事件,并可选择唤醒心跳运行器。它们必须使用 `payload.kind = "systemEvent"`。
|
||||
|
||||
- `wakeMode: "next-heartbeat"`(默认):事件等待下一次计划的心跳。
|
||||
- `wakeMode: "next-heartbeat"`(默认):事件等待下一次计划心跳。
|
||||
- `wakeMode: "now"`:事件触发立即心跳运行。
|
||||
|
||||
当你需要正常的心跳提示 + 主会话上下文时,这是最佳选择。参见[心跳](/gateway/heartbeat)。
|
||||
|
||||
#### 隔离任务(专用定时会话)
|
||||
|
||||
隔离任务在会话 `cron:<jobId>` 中运行专用的智能体回合。
|
||||
隔离任务在会话 `cron:<jobId>` 中运行专用智能体轮次。
|
||||
|
||||
关键行为:
|
||||
|
||||
- 提示以 `[cron:<jobId> <job name>]` 为前缀以便追踪。
|
||||
- 每次运行启动一个**新的会话 id**(没有先前的对话延续)。
|
||||
- 摘要会发布到主会话(前缀 `Cron`,可配置)。
|
||||
- `wakeMode: "now"` 在发布摘要后触发立即心跳。
|
||||
- 如果 `payload.deliver: true`,输出会发送到渠道;否则保持内部。
|
||||
- 提示以 `[cron:<jobId> <任务名称>]` 为前缀,便于追踪。
|
||||
- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。
|
||||
- 如果未指定 `delivery`,隔离任务会默认以“announce”方式投递摘要。
|
||||
- `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。
|
||||
|
||||
对于嘈杂、频繁或不应该刷屏主聊天历史的"后台杂务",使用隔离任务。
|
||||
对于嘈杂、频繁或"后台杂务"类任务,使用隔离任务可以避免污染你的主聊天记录。
|
||||
|
||||
### 负载结构(运行什么)
|
||||
### 负载结构(运行内容)
|
||||
|
||||
支持两种负载类型:
|
||||
|
||||
- `systemEvent`:仅限主会话,通过心跳提示路由。
|
||||
- `agentTurn`:仅限隔离会话,运行专用的智能体回合。
|
||||
- `agentTurn`:仅限隔离会话,运行专用智能体轮次。
|
||||
|
||||
常见的 `agentTurn` 字段:
|
||||
常用 `agentTurn` 字段:
|
||||
|
||||
- `message`:必需的文本提示。
|
||||
- `message`:必填文本提示。
|
||||
- `model` / `thinking`:可选覆盖(见下文)。
|
||||
- `timeoutSeconds`:可选的超时覆盖。
|
||||
- `deliver`:`true` 则将输出发送到渠道目标。
|
||||
- `channel`:`last` 或特定渠道。
|
||||
- `to`:特定于渠道的目标(电话/聊天/频道 id)。
|
||||
- `bestEffortDeliver`:发送失败时避免任务失败。
|
||||
- `timeoutSeconds`:可选超时覆盖。
|
||||
|
||||
隔离选项(仅适用于 `session=isolated`):
|
||||
### 模型和思维覆盖
|
||||
|
||||
- `postToMainPrefix`(CLI:`--post-prefix`):主会话中系统事件的前缀。
|
||||
- `postToMainMode`:`summary`(默认)或 `full`。
|
||||
- `postToMainMaxChars`:当 `postToMainMode=full` 时的最大字符数(默认 8000)。
|
||||
|
||||
### 模型和思考覆盖
|
||||
|
||||
隔离任务(`agentTurn`)可以覆盖模型和思考级别:
|
||||
隔离任务(`agentTurn`)可以覆盖模型和思维级别:
|
||||
|
||||
- `model`:提供商/模型字符串(例如 `anthropic/claude-sonnet-4-20250514`)或别名(例如 `opus`)
|
||||
- `thinking`:思考级别(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`;仅限 GPT-5.2 + Codex 模型)
|
||||
- `thinking`:思维级别(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`;仅限 GPT-5.2 + Codex 模型)
|
||||
|
||||
注意:你也可以在主会话任务上设置 `model`,但它会更改共享的主会话模型。我们建议仅对隔离任务使用模型覆盖,以避免意外的上下文切换。
|
||||
注意:你也可以在主会话任务上设置 `model`,但这会更改共享的主会话模型。我们建议仅对隔离任务使用模型覆盖,以避免意外的上下文切换。
|
||||
|
||||
解析优先级:
|
||||
优先级解析顺序:
|
||||
|
||||
1. 任务负载覆盖(最高)
|
||||
1. 任务负载覆盖(最高优先级)
|
||||
2. 钩子特定默认值(例如 `hooks.gmail.model`)
|
||||
3. 智能体配置默认值
|
||||
|
||||
### 发送(渠道 + 目标)
|
||||
### 投递(渠道 + 目标)
|
||||
|
||||
隔离任务可以将输出发送到渠道。任务负载可以指定:
|
||||
隔离任务可以通过顶层 `delivery` 配置投递输出:
|
||||
|
||||
- `channel`:`whatsapp` / `telegram` / `discord` / `slack` / `mattermost`(插件)/ `signal` / `imessage` / `last`
|
||||
- `to`:特定于渠道的接收者目标
|
||||
- `delivery.mode`:`announce`(投递摘要)或 `none`
|
||||
- `delivery.channel`:`whatsapp` / `telegram` / `discord` / `slack` / `mattermost`(插件)/ `signal` / `imessage` / `last`
|
||||
- `delivery.to`:渠道特定的接收目标
|
||||
- `delivery.bestEffort`:投递失败时避免任务失败
|
||||
|
||||
如果省略 `channel` 或 `to`,定时任务可以回退到主会话的"最后路由"(智能体最后回复的位置)。
|
||||
当启用 announce 投递时,该轮次会抑制消息工具发送;请使用 `delivery.channel`/`delivery.to` 来指定目标。
|
||||
|
||||
发送说明:
|
||||
|
||||
- 如果设置了 `to`,即使省略了 `deliver`,定时任务也会自动发送智能体的最终输出。
|
||||
- 当你想要不带显式 `to` 的最后路由发送时,使用 `deliver: true`。
|
||||
- 使用 `deliver: false` 即使存在 `to` 也保持输出在内部。
|
||||
如果省略 `delivery.channel` 或 `delivery.to`,定时任务会回退到主会话的“最后路由”(智能体最后回复的位置)。
|
||||
|
||||
目标格式提醒:
|
||||
|
||||
- Slack/Discord/Mattermost(插件)目标应使用显式前缀(例如 `channel:<id>`、`user:<id>`)以避免歧义。
|
||||
- Telegram 话题应使用 `:topic:` 形式(见下文)。
|
||||
- Slack/Discord/Mattermost(插件)目标应使用明确前缀(例如 `channel:<id>`、`user:<id>`)以避免歧义。
|
||||
- Telegram 主题应使用 `:topic:` 格式(见下文)。
|
||||
|
||||
#### Telegram 发送目标(话题/论坛帖子)
|
||||
#### Telegram 投递目标(主题/论坛帖子)
|
||||
|
||||
Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发送,你可以将话题/帖子编码到 `to` 字段中:
|
||||
Telegram 通过 `message_thread_id` 支持论坛主题。对于定时任务投递,你可以将主题/帖子编码到 `to` 字段中:
|
||||
|
||||
- `-1001234567890`(仅聊天 id)
|
||||
- `-1001234567890:topic:123`(推荐:显式话题标记)
|
||||
- `-1001234567890`(仅聊天 ID)
|
||||
- `-1001234567890:topic:123`(推荐:明确的主题标记)
|
||||
- `-1001234567890:123`(简写:数字后缀)
|
||||
|
||||
带前缀的目标如 `telegram:...` / `telegram:group:...` 也被接受:
|
||||
带前缀的目标如 `telegram:...` / `telegram:group:...` 也可接受:
|
||||
|
||||
- `telegram:group:-1001234567890:topic:123`
|
||||
|
||||
## 工具调用的 JSON schema
|
||||
## 工具调用的 JSON 模式
|
||||
|
||||
直接调用 Gateway 网关 `cron.*` 工具时(智能体工具调用或 RPC)使用这些结构。CLI 标志接受人类可读的时间格式如 `20m`,但工具调用对 `atMs` 和 `everyMs` 使用纪元毫秒(`at` 时间接受 ISO 时间戳)。
|
||||
直接调用 Gateway网关 `cron.*` 工具(智能体工具调用或 RPC)时使用这些结构。CLI 标志接受人类可读的时间格式如 `20m`,但工具调用应使用 ISO 8601 字符串作为 `schedule.at`,并使用毫秒作为 `schedule.everyMs`。
|
||||
|
||||
### cron.add 参数
|
||||
|
||||
一次性,主会话任务(系统事件):
|
||||
一次性主会话任务(系统事件):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Reminder",
|
||||
"schedule": { "kind": "at", "atMs": 1738262400000 },
|
||||
"schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" },
|
||||
"sessionTarget": "main",
|
||||
"wakeMode": "now",
|
||||
"payload": { "kind": "systemEvent", "text": "Reminder text" },
|
||||
@@ -227,7 +217,7 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
|
||||
}
|
||||
```
|
||||
|
||||
循环,带发送的隔离任务:
|
||||
带投递的周期性隔离任务:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -237,22 +227,24 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
|
||||
"wakeMode": "next-heartbeat",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "Summarize overnight updates.",
|
||||
"deliver": true,
|
||||
"message": "Summarize overnight updates."
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "announce",
|
||||
"channel": "slack",
|
||||
"to": "channel:C1234567890",
|
||||
"bestEffortDeliver": true
|
||||
},
|
||||
"isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }
|
||||
"bestEffort": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `schedule.kind`:`at`(`atMs`)、`every`(`everyMs`)或 `cron`(`expr`,可选 `tz`)。
|
||||
- `atMs` 和 `everyMs` 是纪元毫秒。
|
||||
- `sessionTarget` 必须是 `"main"` 或 `"isolated"` 并且必须与 `payload.kind` 匹配。
|
||||
- 可选字段:`agentId`、`description`、`enabled`、`deleteAfterRun`、`isolation`。
|
||||
- `schedule.kind`:`at`(`at`)、`every`(`everyMs`)或 `cron`(`expr`,可选 `tz`)。
|
||||
- `schedule.at` 接受 ISO 8601(可省略时区;省略时按 UTC 处理)。
|
||||
- `everyMs` 为毫秒数。
|
||||
- `sessionTarget` 必须为 `"main"` 或 `"isolated"`,且必须与 `payload.kind` 匹配。
|
||||
- 可选字段:`agentId`、`description`、`enabled`、`deleteAfterRun`、`delivery`。
|
||||
- `wakeMode` 省略时默认为 `"next-heartbeat"`。
|
||||
|
||||
### cron.update 参数
|
||||
@@ -269,8 +261,8 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
|
||||
|
||||
说明:
|
||||
|
||||
- `jobId` 是规范名称;为了兼容性也接受 `id`。
|
||||
- 在补丁中使用 `agentId: null` 来清除智能体绑定。
|
||||
- `jobId` 是规范字段;`id` 可兼容使用。
|
||||
- 在补丁中使用 `agentId: null` 可清除智能体绑定。
|
||||
|
||||
### cron.run 和 cron.remove 参数
|
||||
|
||||
@@ -282,9 +274,9 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
|
||||
{ "jobId": "job-123" }
|
||||
```
|
||||
|
||||
## 存储和历史
|
||||
## 存储与历史
|
||||
|
||||
- 任务存储:`~/.openclaw/cron/jobs.json`(Gateway 网关管理的 JSON)。
|
||||
- 任务存储:`~/.openclaw/cron/jobs.json`(Gateway网关管理的 JSON)。
|
||||
- 运行历史:`~/.openclaw/cron/runs/<jobId>.jsonl`(JSONL,自动清理)。
|
||||
- 覆盖存储路径:配置中的 `cron.store`。
|
||||
|
||||
@@ -330,7 +322,7 @@ openclaw cron add \
|
||||
--wake now
|
||||
```
|
||||
|
||||
循环隔离任务(发送到 WhatsApp):
|
||||
周期性隔离任务(投递到 WhatsApp):
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
@@ -339,12 +331,12 @@ openclaw cron add \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize inbox + calendar for today." \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
循环隔离任务(发送到 Telegram 话题):
|
||||
周期性隔离任务(投递到 Telegram 主题):
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
@@ -353,12 +345,12 @@ openclaw cron add \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize today; send to the nightly topic." \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
--to "-1001234567890:topic:123"
|
||||
```
|
||||
|
||||
带模型和思考覆盖的隔离任务:
|
||||
带模型和思维覆盖的隔离任务:
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
@@ -369,15 +361,15 @@ openclaw cron add \
|
||||
--message "Weekly deep analysis of project progress." \
|
||||
--model "opus" \
|
||||
--thinking high \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
智能体选择(多智能体设置):
|
||||
智能体选择(多智能体配置):
|
||||
|
||||
```bash
|
||||
# 将任务绑定到智能体"ops"(如果该智能体不存在则回退到默认)
|
||||
# 将任务绑定到智能体 "ops"(如果该智能体不存在则回退到默认智能体)
|
||||
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
|
||||
|
||||
# 切换或清除现有任务的智能体
|
||||
@@ -406,27 +398,27 @@ openclaw cron edit <jobId> \
|
||||
openclaw cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
不创建任务的立即系统事件:
|
||||
不创建任务直接发送系统事件:
|
||||
|
||||
```bash
|
||||
openclaw system event --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## Gateway 网关 API 接口
|
||||
## Gateway网关 API 接口
|
||||
|
||||
- `cron.list`、`cron.status`、`cron.add`、`cron.update`、`cron.remove`
|
||||
- `cron.run`(强制或到期)、`cron.runs`
|
||||
对于不创建任务的立即系统事件,使用 [`openclaw system event`](/cli/system)。
|
||||
如需不创建任务直接发送系统事件,请使用 [`openclaw system event`](/cli/system)。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### "什么都不运行"
|
||||
### "没有任何任务运行"
|
||||
|
||||
- 检查定时任务是否启用:`cron.enabled` 和 `OPENCLAW_SKIP_CRON`。
|
||||
- 检查 Gateway 网关是否持续运行(定时任务在 Gateway 网关进程内运行)。
|
||||
- 对于 `cron` 计划:确认时区(`--tz`)与主机时区的关系。
|
||||
- 检查定时任务是否已启用:`cron.enabled` 和 `OPENCLAW_SKIP_CRON`。
|
||||
- 检查 Gateway网关是否持续运行(定时任务运行在 Gateway网关进程内部)。
|
||||
- 对于 `cron` 调度:确认时区(`--tz`)与主机时区的关系。
|
||||
|
||||
### Telegram 发送到错误的位置
|
||||
### Telegram 投递到了错误的位置
|
||||
|
||||
- 对于论坛话题,使用 `-100…:topic:<id>` 以确保明确无歧义。
|
||||
- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务发送接受它们并仍然正确解析话题 ID。
|
||||
- 对于论坛主题,使用 `-100…:topic:<id>` 以确保明确无歧义。
|
||||
- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务投递接受这些前缀并仍能正确解析主题 ID。
|
||||
|
||||
@@ -97,7 +97,7 @@ x-i18n:
|
||||
- **精确定时**:支持带时区的 5 字段 cron 表达式。
|
||||
- **会话隔离**:在 `cron:<jobId>` 中运行,不会污染主会话历史。
|
||||
- **模型覆盖**:可按任务使用更便宜或更强大的模型。
|
||||
- **投递控制**:可直接投递到渠道;默认仍会向主会话发布摘要(可配置)。
|
||||
- **投递控制**:隔离任务默认以 `announce` 投递摘要,可选 `none` 仅内部运行。
|
||||
- **无需智能体上下文**:即使主会话空闲或已压缩,也能运行。
|
||||
- **一次性支持**:`--at` 用于精确的未来时间戳。
|
||||
|
||||
@@ -111,7 +111,7 @@ openclaw cron add \
|
||||
--session isolated \
|
||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||
--model opus \
|
||||
--deliver \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
@@ -180,7 +180,7 @@ openclaw cron add \
|
||||
|
||||
```bash
|
||||
# 每天早上 7 点的早间简报
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
|
||||
|
||||
# 每周一上午 9 点的项目回顾
|
||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||
@@ -219,13 +219,13 @@ Lobster 是用于**多步骤工具管道**的工作流运行时,适用于需
|
||||
|
||||
心跳和定时任务都可以与主会话交互,但方式不同:
|
||||
|
||||
| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) |
|
||||
| ------ | ------------------------ | ---------------------- | ------------------ |
|
||||
| 会话 | 主会话 | 主会话(通过系统事件) | `cron:<jobId>` |
|
||||
| 历史 | 共享 | 共享 | 每次运行全新 |
|
||||
| 上下文 | 完整 | 完整 | 无(从零开始) |
|
||||
| 模型 | 主会话模型 | 主会话模型 | 可覆盖 |
|
||||
| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | 摘要发布到主会话 |
|
||||
| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) |
|
||||
| ------ | ------------------------ | ---------------------- | --------------------- |
|
||||
| 会话 | 主会话 | 主会话(通过系统事件) | `cron:<jobId>` |
|
||||
| 历史 | 共享 | 共享 | 每次运行全新 |
|
||||
| 上下文 | 完整 | 完整 | 无(从零开始) |
|
||||
| 模型 | 主会话模型 | 主会话模型 | 可覆盖 |
|
||||
| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | announce 摘要(默认) |
|
||||
|
||||
### 何时使用主会话定时任务
|
||||
|
||||
@@ -250,7 +250,7 @@ openclaw cron add \
|
||||
|
||||
- 无先前上下文的全新环境
|
||||
- 不同的模型或思维设置
|
||||
- 输出直接投递到渠道(摘要默认仍会发布到主会话)
|
||||
- 输出可通过 `announce` 直接投递摘要(或用 `none` 仅内部运行)
|
||||
- 不会把主会话搞得杂乱的历史记录
|
||||
|
||||
```bash
|
||||
@@ -261,7 +261,7 @@ openclaw cron add \
|
||||
--message "Weekly codebase analysis..." \
|
||||
--model opus \
|
||||
--thinking high \
|
||||
--deliver
|
||||
--announce
|
||||
```
|
||||
|
||||
## 成本考量
|
||||
|
||||
@@ -28,8 +28,8 @@ OpenClaw 可以在你已经使用的任何聊天应用上与你交流。每个
|
||||
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
|
||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket;频道、群组、私信(插件,需单独安装)。
|
||||
- [Signal](/channels/signal) — signal-cli;注重隐私。
|
||||
- [BlueBubbles](/channels/bluebubbles) — **iMessage 推荐方案**;使用 BlueBubbles macOS 服务器 REST API,完整功能支持(编辑、撤回、特效、表情回应、群组管理——编辑功能目前在 macOS 26 Tahoe 上存在问题)。
|
||||
- [iMessage](/channels/imessage) — 仅限 macOS;通过 imsg 原生集成(旧版方案,新部署建议使用 BlueBubbles)。
|
||||
- [BlueBubbles](/channels/bluebubbles) — **推荐用于 iMessage**;使用 BlueBubbles macOS 服务器 REST API,功能完整(编辑、撤回、特效、回应、群组管理——编辑功能在 macOS 26 Tahoe 上目前不可用)。
|
||||
- [iMessage(旧版)](/channels/imessage) — 通过 imsg CLI 的旧版 macOS 集成(已弃用,新设置请使用 BlueBubbles)。
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework;企业支持(插件,需单独安装)。
|
||||
- [LINE](/channels/line) — LINE Messaging API 机器人(插件,需单独安装)。
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — 通过 Nextcloud Talk 的自托管聊天(插件,需单独安装)。
|
||||
|
||||
@@ -23,12 +23,17 @@ x-i18n:
|
||||
|
||||
提示:运行 `openclaw cron --help` 查看完整的命令集。
|
||||
|
||||
## 常用编辑
|
||||
说明:隔离式 `cron add` 任务默认使用 `--announce` 投递摘要。使用 `--no-deliver` 仅内部运行。
|
||||
`--deliver` 仍作为 `--announce` 的弃用别名保留。
|
||||
|
||||
说明:一次性(`--at`)任务成功后默认删除。使用 `--keep-after-run` 保留。
|
||||
|
||||
## 常见编辑
|
||||
|
||||
更新投递设置而不更改消息:
|
||||
|
||||
```bash
|
||||
openclaw cron edit <job-id> --deliver --channel telegram --to "123456789"
|
||||
openclaw cron edit <job-id> --announce --channel telegram --to "123456789"
|
||||
```
|
||||
|
||||
为隔离的作业禁用投递:
|
||||
|
||||
@@ -1008,7 +1008,7 @@ pnpm add -g clawhub
|
||||
|
||||
**能否从 Linux 运行仅限 Apple/macOS 的 Skills**
|
||||
|
||||
不能直接运行。macOS Skills 受 `metadata.openclaw.os` 和所需二进制文件限制,Skills 只有在 **Gateway 网关主机**上符合条件时才会出现在系统提示中。在 Linux 上,`darwin` 专用 Skills(如 `imsg`、`apple-notes`、`apple-reminders`)不会加载,除非你覆盖限制。
|
||||
不能直接运行。macOS Skills 受 `metadata.openclaw.os` 和所需二进制文件限制,Skills 只有在 **Gateway 网关主机**上符合条件时才会出现在系统提示中。在 Linux 上,`darwin` 专用 Skills(如 `apple-notes`、`apple-reminders`、`things-mac`)不会加载,除非你覆盖限制。
|
||||
|
||||
你有三种支持的模式:
|
||||
|
||||
|
||||
45
extensions/bluebubbles/README.md
Normal file
45
extensions/bluebubbles/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# BlueBubbles extension (developer reference)
|
||||
|
||||
This directory contains the **BlueBubbles external channel plugin** for OpenClaw.
|
||||
|
||||
If you’re looking for **how to use BlueBubbles as an agent/tool user**, see:
|
||||
|
||||
- `skills/bluebubbles/SKILL.md`
|
||||
|
||||
## Layout
|
||||
|
||||
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
||||
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
|
||||
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
|
||||
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
|
||||
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
|
||||
|
||||
## Internal helpers (use these, not raw API calls)
|
||||
|
||||
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
|
||||
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
|
||||
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
|
||||
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
|
||||
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
|
||||
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
|
||||
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
|
||||
|
||||
## Webhooks
|
||||
|
||||
- BlueBubbles posts JSON to the gateway HTTP server.
|
||||
- Normalize sender/chat IDs defensively (payloads vary by version).
|
||||
- Skip messages marked as from self.
|
||||
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers.
|
||||
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
|
||||
|
||||
## Config (core)
|
||||
|
||||
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
|
||||
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
|
||||
|
||||
## Message tool notes
|
||||
|
||||
- **Reactions:** the `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`.
|
||||
Example:
|
||||
`action=react target=+15551234567 messageId=ABC123 emoji=❤️`
|
||||
47
extensions/feishu/README.md
Normal file
47
extensions/feishu/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @openclaw/feishu
|
||||
|
||||
Feishu/Lark channel plugin for OpenClaw (WebSocket bot events).
|
||||
|
||||
## Install (local checkout)
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/feishu
|
||||
```
|
||||
|
||||
## Install (npm)
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/feishu
|
||||
```
|
||||
|
||||
Onboarding: select Feishu/Lark and confirm the install prompt to fetch the plugin automatically.
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
default: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
domain: "feishu",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "open",
|
||||
blockStreaming: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Lark (global) tenants should set `domain: "lark"` (or a full https:// domain).
|
||||
|
||||
Restart the gateway after config changes.
|
||||
|
||||
## Docs
|
||||
|
||||
https://docs.openclaw.ai/channels/feishu
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, DmPolicy } from "./types.js";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
|
||||
@@ -65,14 +65,16 @@ async function promptMatrixAllowFrom(params: {
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Matrix allowFrom (username or user id)",
|
||||
message: "Matrix allowFrom (full @user:server; display name only if unique)",
|
||||
placeholder: "@user:server",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const resolvedIds: string[] = [];
|
||||
let unresolved: string[] = [];
|
||||
const pending: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const unresolvedNotes: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (isFullUserId(part)) {
|
||||
@@ -83,28 +85,33 @@ async function promptMatrixAllowFrom(params: {
|
||||
unresolved.push(part);
|
||||
continue;
|
||||
}
|
||||
const results = await listMatrixDirectoryPeersLive({
|
||||
pending.push(part);
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
const results = await resolveMatrixTargets({
|
||||
cfg,
|
||||
query: part,
|
||||
limit: 5,
|
||||
inputs: pending,
|
||||
kind: "user",
|
||||
}).catch(() => []);
|
||||
const match = results.find((result) => result.id);
|
||||
if (match?.id) {
|
||||
resolvedIds.push(match.id);
|
||||
if (results.length > 1) {
|
||||
await prompter.note(
|
||||
`Multiple matches for "${part}", using ${match.id}.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result?.resolved && result.id) {
|
||||
resolvedIds.push(result.id);
|
||||
continue;
|
||||
}
|
||||
if (result?.input) {
|
||||
unresolved.push(result.input);
|
||||
if (result.note) {
|
||||
unresolvedNotes.push(`${result.input}: ${result.note}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unresolved.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved;
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
|
||||
`Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
continue;
|
||||
|
||||
@@ -121,7 +121,6 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
}).allowed;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
@@ -143,7 +142,6 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: roomAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
if (!groupAllow.allowed) {
|
||||
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
|
||||
@@ -158,7 +156,6 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
const dmAllowed = resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
}).allowed;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
|
||||
@@ -54,7 +54,7 @@ function payloadToInboundMessage(
|
||||
roomToken: payload.target.id,
|
||||
roomName: payload.target.name,
|
||||
senderId: payload.actor.id,
|
||||
senderName: payload.actor.name,
|
||||
senderName: payload.actor.name ?? "",
|
||||
text: payload.object.content || payload.object.name || "",
|
||||
mediaType: payload.object.mediaType || "text/plain",
|
||||
timestamp: Date.now(),
|
||||
|
||||
33
extensions/nextcloud-talk/src/policy.test.ts
Normal file
33
extensions/nextcloud-talk/src/policy.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveNextcloudTalkAllowlistMatch } from "./policy.js";
|
||||
|
||||
describe("nextcloud-talk policy", () => {
|
||||
describe("resolveNextcloudTalkAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "user-id",
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows sender id match with normalization", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["nc:User-Id"],
|
||||
senderId: "user-id",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("blocks when sender id does not match", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["allowed"],
|
||||
senderId: "other",
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,7 @@ export function normalizeNextcloudTalkAllowlist(
|
||||
export function resolveNextcloudTalkAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number> | undefined;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): AllowlistMatch<"wildcard" | "id" | "name"> {
|
||||
}): AllowlistMatch<"wildcard" | "id"> {
|
||||
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
|
||||
if (allowFrom.length === 0) {
|
||||
return { allowed: false };
|
||||
@@ -42,10 +41,6 @@ export function resolveNextcloudTalkAllowlistMatch(params: {
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||
if (senderName && allowFrom.includes(senderName)) {
|
||||
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
@@ -132,7 +127,6 @@ export function resolveNextcloudTalkGroupAllow(params: {
|
||||
outerAllowFrom: Array<string | number> | undefined;
|
||||
innerAllowFrom: Array<string | number> | undefined;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
|
||||
if (params.groupPolicy === "disabled") {
|
||||
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
||||
@@ -150,12 +144,10 @@ export function resolveNextcloudTalkGroupAllow(params: {
|
||||
const outerMatch = resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: params.outerAllowFrom,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
});
|
||||
const innerMatch = resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: params.innerAllowFrom,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
});
|
||||
const allowed = resolveNestedAllowlistDecision({
|
||||
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
type ResolvedTelegramAccount,
|
||||
type TelegramProbe,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
|
||||
@@ -60,7 +61,7 @@ function parseThreadId(threadId?: string | number | null) {
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
...meta,
|
||||
@@ -327,11 +328,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
|
||||
return undefined;
|
||||
}
|
||||
const botId =
|
||||
(probe as { ok?: boolean; bot?: { id?: number } })?.ok &&
|
||||
(probe as { bot?: { id?: number } }).bot?.id != null
|
||||
? (probe as { bot: { id: number } }).bot.id
|
||||
: null;
|
||||
const botId = probe?.ok && probe.bot?.id != null ? probe.bot.id : null;
|
||||
if (!botId) {
|
||||
return {
|
||||
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
|
||||
@@ -357,15 +354,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.channels?.telegram?.groups;
|
||||
const allowUnmentionedGroups =
|
||||
Boolean(
|
||||
groups?.["*"] && (groups["*"] as { requireMention?: boolean }).requireMention === false,
|
||||
) ||
|
||||
groups?.["*"]?.requireMention === false ||
|
||||
Object.entries(groups ?? {}).some(
|
||||
([key, value]) =>
|
||||
key !== "*" &&
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
(value as { requireMention?: boolean }).requireMention === false,
|
||||
([key, value]) => key !== "*" && value?.requireMention === false,
|
||||
);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -17,6 +17,11 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
||||
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
|
||||
tailscale: { mode: "off", path: "/voice/webhook" },
|
||||
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
|
||||
webhookSecurity: {
|
||||
allowedHosts: [],
|
||||
trustForwardingHeaders: false,
|
||||
trustedProxyIPs: [],
|
||||
},
|
||||
streaming: {
|
||||
enabled: false,
|
||||
sttProvider: "openai-realtime",
|
||||
|
||||
@@ -211,16 +211,37 @@ export const VoiceCallTunnelConfigSchema = z
|
||||
* will be allowed only for loopback requests (ngrok local agent).
|
||||
*/
|
||||
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
|
||||
/**
|
||||
* Legacy ngrok free tier compatibility mode (deprecated).
|
||||
* Use allowNgrokFreeTierLoopbackBypass instead.
|
||||
*/
|
||||
allowNgrokFreeTier: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
|
||||
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Webhook Security Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallWebhookSecurityConfigSchema = z
|
||||
.object({
|
||||
/**
|
||||
* Allowed hostnames for webhook URL reconstruction.
|
||||
* Only these hosts are accepted from forwarding headers.
|
||||
*/
|
||||
allowedHosts: z.array(z.string().min(1)).default([]),
|
||||
/**
|
||||
* Trust X-Forwarded-* headers without a hostname allowlist.
|
||||
* WARNING: Only enable if you trust your proxy configuration.
|
||||
*/
|
||||
trustForwardingHeaders: z.boolean().default(false),
|
||||
/**
|
||||
* Trusted proxy IP addresses. Forwarded headers are only trusted when
|
||||
* the remote IP matches one of these addresses.
|
||||
*/
|
||||
trustedProxyIPs: z.array(z.string().min(1)).default([]),
|
||||
})
|
||||
.strict()
|
||||
.default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
|
||||
export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Outbound Call Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -339,6 +360,9 @@ export const VoiceCallConfigSchema = z
|
||||
/** Tunnel configuration (unified ngrok/tailscale) */
|
||||
tunnel: VoiceCallTunnelConfigSchema,
|
||||
|
||||
/** Webhook signature reconstruction and proxy trust configuration */
|
||||
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
|
||||
|
||||
/** Real-time audio streaming configuration */
|
||||
streaming: VoiceCallStreamingConfigSchema,
|
||||
|
||||
@@ -409,10 +433,21 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
|
||||
allowNgrokFreeTierLoopbackBypass: false,
|
||||
};
|
||||
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
|
||||
resolved.tunnel.allowNgrokFreeTierLoopbackBypass || resolved.tunnel.allowNgrokFreeTier || false;
|
||||
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
|
||||
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
|
||||
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
|
||||
|
||||
// Webhook Security Config
|
||||
resolved.webhookSecurity = resolved.webhookSecurity ?? {
|
||||
allowedHosts: [],
|
||||
trustForwardingHeaders: false,
|
||||
trustedProxyIPs: [],
|
||||
};
|
||||
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
|
||||
resolved.webhookSecurity.trustForwardingHeaders =
|
||||
resolved.webhookSecurity.trustForwardingHeaders ?? false;
|
||||
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PlivoConfig } from "../config.js";
|
||||
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
|
||||
import type {
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
@@ -23,6 +23,8 @@ export interface PlivoProviderOptions {
|
||||
skipVerification?: boolean;
|
||||
/** Outbound ring timeout in seconds */
|
||||
ringTimeoutSec?: number;
|
||||
/** Webhook security options (forwarded headers/allowlist) */
|
||||
webhookSecurity?: WebhookSecurityConfig;
|
||||
}
|
||||
|
||||
type PendingSpeak = { text: string; locale?: string };
|
||||
@@ -92,6 +94,10 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
const result = verifyPlivoWebhook(ctx, this.authToken, {
|
||||
publicUrl: this.options.publicUrl,
|
||||
skipVerification: this.options.skipVerification,
|
||||
allowedHosts: this.options.webhookSecurity?.allowedHosts,
|
||||
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
|
||||
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
|
||||
remoteIP: ctx.remoteAddress,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -112,7 +118,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
// Keep providerCallId mapping for later call control.
|
||||
const callUuid = parsed.get("CallUUID") || undefined;
|
||||
if (callUuid) {
|
||||
const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
|
||||
const webhookBase = this.baseWebhookUrlFromCtx(ctx);
|
||||
if (webhookBase) {
|
||||
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
|
||||
}
|
||||
@@ -444,7 +450,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
ctx: WebhookContext,
|
||||
opts: { flow: string; callId?: string },
|
||||
): string | null {
|
||||
const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
|
||||
const base = this.baseWebhookUrlFromCtx(ctx);
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
@@ -458,9 +464,16 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
|
||||
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
|
||||
try {
|
||||
const u = new URL(reconstructWebhookUrl(ctx));
|
||||
const u = new URL(
|
||||
reconstructWebhookUrl(ctx, {
|
||||
allowedHosts: this.options.webhookSecurity?.allowedHosts,
|
||||
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
|
||||
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
|
||||
remoteIP: ctx.remoteAddress,
|
||||
}),
|
||||
);
|
||||
return `${u.origin}${u.pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { TwilioConfig } from "../config.js";
|
||||
import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
|
||||
import type { MediaStreamHandler } from "../media-stream.js";
|
||||
import type { TelephonyTtsProvider } from "../telephony-tts.js";
|
||||
import type {
|
||||
@@ -38,6 +38,8 @@ export interface TwilioProviderOptions {
|
||||
streamPath?: string;
|
||||
/** Skip webhook signature verification (development only) */
|
||||
skipVerification?: boolean;
|
||||
/** Webhook security options (forwarded headers/allowlist) */
|
||||
webhookSecurity?: WebhookSecurityConfig;
|
||||
}
|
||||
|
||||
export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
@@ -12,6 +12,10 @@ export function verifyTwilioProviderWebhook(params: {
|
||||
publicUrl: params.currentPublicUrl || undefined,
|
||||
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
|
||||
skipVerification: params.options.skipVerification,
|
||||
allowedHosts: params.options.webhookSecurity?.allowedHosts,
|
||||
trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
|
||||
trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
|
||||
remoteIP: params.ctx.remoteAddress,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -44,7 +44,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
||||
const allowNgrokFreeTierLoopbackBypass =
|
||||
config.tunnel?.provider === "ngrok" &&
|
||||
isLoopbackBind(config.serve?.bind) &&
|
||||
(config.tunnel?.allowNgrokFreeTierLoopbackBypass || config.tunnel?.allowNgrokFreeTier || false);
|
||||
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
|
||||
|
||||
switch (config.provider) {
|
||||
case "telnyx":
|
||||
@@ -70,6 +70,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
||||
publicUrl: config.publicUrl,
|
||||
skipVerification: config.skipSignatureVerification,
|
||||
streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
|
||||
webhookSecurity: config.webhookSecurity,
|
||||
},
|
||||
);
|
||||
case "plivo":
|
||||
@@ -82,6 +83,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
||||
publicUrl: config.publicUrl,
|
||||
skipVerification: config.skipSignatureVerification,
|
||||
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
|
||||
webhookSecurity: config.webhookSecurity,
|
||||
},
|
||||
);
|
||||
case "mock":
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("verifyTwilioWebhook", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid signatures even with ngrok free tier enabled", () => {
|
||||
it("rejects invalid signatures even when attacker injects forwarded host", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
|
||||
@@ -212,14 +212,13 @@ describe("verifyTwilioWebhook", () => {
|
||||
rawBody: postBody,
|
||||
url: "http://127.0.0.1:3334/voice/webhook",
|
||||
method: "POST",
|
||||
remoteAddress: "203.0.113.10",
|
||||
},
|
||||
authToken,
|
||||
{ allowNgrokFreeTierLoopbackBypass: true },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.isNgrokFreeTier).toBe(true);
|
||||
// X-Forwarded-Host is ignored by default, so URL uses Host header
|
||||
expect(result.isNgrokFreeTier).toBe(false);
|
||||
expect(result.reason).toMatch(/Invalid signature/);
|
||||
});
|
||||
|
||||
@@ -248,4 +247,131 @@ describe("verifyTwilioWebhook", () => {
|
||||
expect(result.isNgrokFreeTier).toBe(true);
|
||||
expect(result.reason).toMatch(/compatibility mode/);
|
||||
});
|
||||
|
||||
it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
|
||||
// Attacker tries to inject their host - should be ignored
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "legitimate.example.com",
|
||||
"x-forwarded-host": "attacker.evil.com",
|
||||
"x-twilio-signature": "invalid",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://localhost:3000/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
// Attacker's host is ignored - uses Host header instead
|
||||
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
|
||||
});
|
||||
|
||||
it("uses X-Forwarded-Host when allowedHosts whitelist is provided", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
const webhookUrl = "https://myapp.ngrok.io/voice/webhook";
|
||||
|
||||
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "localhost:3000",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "myapp.ngrok.io",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://localhost:3000/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
{ allowedHosts: ["myapp.ngrok.io"] },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.verificationUrl).toBe(webhookUrl);
|
||||
});
|
||||
|
||||
it("rejects X-Forwarded-Host not in allowedHosts whitelist", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "localhost:3000",
|
||||
"x-forwarded-host": "attacker.evil.com",
|
||||
"x-twilio-signature": "invalid",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://localhost:3000/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
{ allowedHosts: ["myapp.ngrok.io", "webhook.example.com"] },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
// Attacker's host not in whitelist, falls back to Host header
|
||||
expect(result.verificationUrl).toBe("https://localhost/voice/webhook");
|
||||
});
|
||||
|
||||
it("trusts forwarding headers only from trusted proxy IPs", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
const webhookUrl = "https://proxy.example.com/voice/webhook";
|
||||
|
||||
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "localhost:3000",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "proxy.example.com",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://localhost:3000/voice/webhook",
|
||||
method: "POST",
|
||||
remoteAddress: "203.0.113.10",
|
||||
},
|
||||
authToken,
|
||||
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.verificationUrl).toBe(webhookUrl);
|
||||
});
|
||||
|
||||
it("ignores forwarding headers when trustedProxyIPs are set but remote IP is missing", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "legitimate.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "proxy.example.com",
|
||||
"x-twilio-signature": "invalid",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://localhost:3000/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,9 +57,119 @@ function timingSafeEqual(a: string, b: string): boolean {
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for secure URL reconstruction.
|
||||
*/
|
||||
export interface WebhookUrlOptions {
|
||||
/**
|
||||
* Whitelist of allowed hostnames. If provided, only these hosts will be
|
||||
* accepted from forwarding headers. This prevents host header injection attacks.
|
||||
*
|
||||
* SECURITY: You must provide this OR set trustForwardingHeaders=true to use
|
||||
* X-Forwarded-Host headers. Without either, forwarding headers are ignored.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
/**
|
||||
* Explicitly trust X-Forwarded-* headers without a whitelist.
|
||||
* WARNING: Only set this to true if you trust your proxy configuration
|
||||
* and understand the security implications.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
trustForwardingHeaders?: boolean;
|
||||
/**
|
||||
* List of trusted proxy IP addresses. X-Forwarded-* headers will only be
|
||||
* trusted if the request comes from one of these IPs.
|
||||
* Requires remoteIP to be set for validation.
|
||||
*/
|
||||
trustedProxyIPs?: string[];
|
||||
/**
|
||||
* The IP address of the incoming request (for proxy validation).
|
||||
*/
|
||||
remoteIP?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a hostname matches RFC 1123 format.
|
||||
* Prevents injection of malformed hostnames.
|
||||
*/
|
||||
function isValidHostname(hostname: string): boolean {
|
||||
if (!hostname || hostname.length > 253) {
|
||||
return false;
|
||||
}
|
||||
// RFC 1123 hostname: alphanumeric, hyphens, dots
|
||||
// Also allow ngrok/tunnel subdomains
|
||||
const hostnameRegex =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
return hostnameRegex.test(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract hostname from a host header value.
|
||||
* Handles IPv6 addresses and prevents injection via malformed values.
|
||||
*/
|
||||
function extractHostname(hostHeader: string): string | null {
|
||||
if (!hostHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
|
||||
// Handle IPv6 addresses: [::1]:8080
|
||||
if (hostHeader.startsWith("[")) {
|
||||
const endBracket = hostHeader.indexOf("]");
|
||||
if (endBracket === -1) {
|
||||
return null; // Malformed IPv6
|
||||
}
|
||||
hostname = hostHeader.substring(1, endBracket);
|
||||
return hostname.toLowerCase();
|
||||
}
|
||||
|
||||
// Handle IPv4/domain with optional port
|
||||
// Check for @ which could indicate user info injection attempt
|
||||
if (hostHeader.includes("@")) {
|
||||
return null; // Reject potential injection: attacker.com:80@legitimate.com
|
||||
}
|
||||
|
||||
hostname = hostHeader.split(":")[0];
|
||||
|
||||
// Validate the extracted hostname
|
||||
if (!isValidHostname(hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hostname.toLowerCase();
|
||||
}
|
||||
|
||||
function extractHostnameFromHeader(headerValue: string): string | null {
|
||||
const first = headerValue.split(",")[0]?.trim();
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
return extractHostname(first);
|
||||
}
|
||||
|
||||
function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
|
||||
if (!allowedHosts || allowedHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalized = new Set<string>();
|
||||
for (const host of allowedHosts) {
|
||||
const extracted = extractHostname(host.trim());
|
||||
if (extracted) {
|
||||
normalized.add(extracted);
|
||||
}
|
||||
}
|
||||
return normalized.size > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the public webhook URL from request headers.
|
||||
*
|
||||
* SECURITY: This function validates host headers to prevent host header
|
||||
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
|
||||
* always provide allowedHosts to whitelist valid hostnames.
|
||||
*
|
||||
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
|
||||
* used by Twilio differs from the local request URL. We use standard
|
||||
* forwarding headers to reconstruct it.
|
||||
@@ -70,17 +180,84 @@ function timingSafeEqual(a: string, b: string): boolean {
|
||||
* 3. Ngrok-Forwarded-Host (ngrok specific)
|
||||
* 4. Host header (direct connection)
|
||||
*/
|
||||
export function reconstructWebhookUrl(ctx: WebhookContext): string {
|
||||
export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
|
||||
const { headers } = ctx;
|
||||
|
||||
const proto = getHeader(headers, "x-forwarded-proto") || "https";
|
||||
// SECURITY: Only trust forwarding headers if explicitly configured.
|
||||
// Either allowedHosts must be set (for whitelist validation) or
|
||||
// trustForwardingHeaders must be true (explicit opt-in to trust).
|
||||
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
|
||||
const hasAllowedHosts = allowedHosts !== null;
|
||||
const explicitlyTrusted = options?.trustForwardingHeaders === true;
|
||||
|
||||
const forwardedHost =
|
||||
getHeader(headers, "x-forwarded-host") ||
|
||||
getHeader(headers, "x-original-host") ||
|
||||
getHeader(headers, "ngrok-forwarded-host") ||
|
||||
getHeader(headers, "host") ||
|
||||
"";
|
||||
// Also check trusted proxy IPs if configured
|
||||
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
|
||||
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
|
||||
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
|
||||
const fromTrustedProxy =
|
||||
!hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
|
||||
|
||||
// Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
|
||||
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
|
||||
|
||||
const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
|
||||
|
||||
// Determine protocol - only trust X-Forwarded-Proto from trusted proxies
|
||||
let proto = "https";
|
||||
if (shouldTrustForwardingHeaders) {
|
||||
const forwardedProto = getHeader(headers, "x-forwarded-proto");
|
||||
if (forwardedProto === "http" || forwardedProto === "https") {
|
||||
proto = forwardedProto;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine host - with security validation
|
||||
let host: string | null = null;
|
||||
|
||||
if (shouldTrustForwardingHeaders) {
|
||||
// Try forwarding headers in priority order
|
||||
const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
|
||||
|
||||
for (const headerName of forwardingHeaders) {
|
||||
const headerValue = getHeader(headers, headerName);
|
||||
if (headerValue) {
|
||||
const extracted = extractHostnameFromHeader(headerValue);
|
||||
if (extracted && isAllowedForwardedHost(extracted)) {
|
||||
host = extracted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Host header if no valid forwarding header found
|
||||
if (!host) {
|
||||
const hostHeader = getHeader(headers, "host");
|
||||
if (hostHeader) {
|
||||
const extracted = extractHostnameFromHeader(hostHeader);
|
||||
if (extracted) {
|
||||
host = extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try to extract from ctx.url
|
||||
if (!host) {
|
||||
try {
|
||||
const parsed = new URL(ctx.url);
|
||||
const extracted = extractHostname(parsed.host);
|
||||
if (extracted) {
|
||||
host = extracted;
|
||||
}
|
||||
} catch {
|
||||
// URL parsing failed - use empty string (will result in invalid URL)
|
||||
host = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
host = "";
|
||||
}
|
||||
|
||||
// Extract path from the context URL (fallback to "/" on parse failure)
|
||||
let path = "/";
|
||||
@@ -91,15 +268,16 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
|
||||
// URL parsing failed
|
||||
}
|
||||
|
||||
// Remove port from host (ngrok URLs don't have ports)
|
||||
const host = forwardedHost.split(":")[0] || forwardedHost;
|
||||
|
||||
return `${proto}://${host}${path}`;
|
||||
}
|
||||
|
||||
function buildTwilioVerificationUrl(ctx: WebhookContext, publicUrl?: string): string {
|
||||
function buildTwilioVerificationUrl(
|
||||
ctx: WebhookContext,
|
||||
publicUrl?: string,
|
||||
urlOptions?: WebhookUrlOptions,
|
||||
): string {
|
||||
if (!publicUrl) {
|
||||
return reconstructWebhookUrl(ctx);
|
||||
return reconstructWebhookUrl(ctx, urlOptions);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -154,9 +332,6 @@ export interface TwilioVerificationResult {
|
||||
|
||||
/**
|
||||
* Verify Twilio webhook with full context and detailed result.
|
||||
*
|
||||
* Handles the special case of ngrok free tier where signature validation
|
||||
* may fail due to URL discrepancies (ngrok adds interstitial page handling).
|
||||
*/
|
||||
export function verifyTwilioWebhook(
|
||||
ctx: WebhookContext,
|
||||
@@ -168,6 +343,26 @@ export function verifyTwilioWebhook(
|
||||
allowNgrokFreeTierLoopbackBypass?: boolean;
|
||||
/** Skip verification entirely (only for development) */
|
||||
skipVerification?: boolean;
|
||||
/**
|
||||
* Whitelist of allowed hostnames for host header validation.
|
||||
* Prevents host header injection attacks.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
/**
|
||||
* Explicitly trust X-Forwarded-* headers without a whitelist.
|
||||
* WARNING: Only enable if you trust your proxy configuration.
|
||||
* @default false
|
||||
*/
|
||||
trustForwardingHeaders?: boolean;
|
||||
/**
|
||||
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
|
||||
* be trusted from these IPs.
|
||||
*/
|
||||
trustedProxyIPs?: string[];
|
||||
/**
|
||||
* The remote IP address of the request (for proxy validation).
|
||||
*/
|
||||
remoteIP?: string;
|
||||
},
|
||||
): TwilioVerificationResult {
|
||||
// Allow skipping verification for development/testing
|
||||
@@ -181,8 +376,16 @@ export function verifyTwilioWebhook(
|
||||
return { ok: false, reason: "Missing X-Twilio-Signature header" };
|
||||
}
|
||||
|
||||
const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
|
||||
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
|
||||
|
||||
// Reconstruct the URL Twilio used
|
||||
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
|
||||
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
|
||||
allowedHosts: options?.allowedHosts,
|
||||
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
|
||||
trustedProxyIPs: options?.trustedProxyIPs,
|
||||
remoteIP: options?.remoteIP,
|
||||
});
|
||||
|
||||
// Parse the body as URL-encoded params
|
||||
const params = new URLSearchParams(ctx.rawBody);
|
||||
@@ -198,11 +401,7 @@ export function verifyTwilioWebhook(
|
||||
const isNgrokFreeTier =
|
||||
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|
||||
|
||||
if (
|
||||
isNgrokFreeTier &&
|
||||
options?.allowNgrokFreeTierLoopbackBypass &&
|
||||
isLoopbackAddress(ctx.remoteAddress)
|
||||
) {
|
||||
if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
|
||||
console.warn(
|
||||
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
|
||||
);
|
||||
@@ -384,6 +583,26 @@ export function verifyPlivoWebhook(
|
||||
publicUrl?: string;
|
||||
/** Skip verification entirely (only for development) */
|
||||
skipVerification?: boolean;
|
||||
/**
|
||||
* Whitelist of allowed hostnames for host header validation.
|
||||
* Prevents host header injection attacks.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
/**
|
||||
* Explicitly trust X-Forwarded-* headers without a whitelist.
|
||||
* WARNING: Only enable if you trust your proxy configuration.
|
||||
* @default false
|
||||
*/
|
||||
trustForwardingHeaders?: boolean;
|
||||
/**
|
||||
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
|
||||
* be trusted from these IPs.
|
||||
*/
|
||||
trustedProxyIPs?: string[];
|
||||
/**
|
||||
* The remote IP address of the request (for proxy validation).
|
||||
*/
|
||||
remoteIP?: string;
|
||||
},
|
||||
): PlivoVerificationResult {
|
||||
if (options?.skipVerification) {
|
||||
@@ -395,7 +614,12 @@ export function verifyPlivoWebhook(
|
||||
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
|
||||
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
|
||||
|
||||
const reconstructed = reconstructWebhookUrl(ctx);
|
||||
const reconstructed = reconstructWebhookUrl(ctx, {
|
||||
allowedHosts: options?.allowedHosts,
|
||||
trustForwardingHeaders: options?.trustForwardingHeaders,
|
||||
trustedProxyIPs: options?.trustedProxyIPs,
|
||||
remoteIP: options?.remoteIP,
|
||||
});
|
||||
let verificationUrl = reconstructed;
|
||||
if (options?.publicUrl) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.2.2",
|
||||
"version": "2026.2.3",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
@@ -32,7 +32,7 @@
|
||||
"android:install": "cd apps/android && ./gradlew :app:installDebug",
|
||||
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity",
|
||||
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
|
||||
"build": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||
"build": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm tsgo && pnpm lint && pnpm format",
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||
|
||||
@@ -9,6 +9,9 @@ set -euo pipefail
|
||||
# - dist/OpenClaw-<version>.dmg
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
|
||||
PRODUCT="OpenClaw"
|
||||
BUILD_CONFIG="${BUILD_CONFIG:-release}"
|
||||
|
||||
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
|
||||
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
|
||||
@@ -25,8 +28,10 @@ VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Co
|
||||
ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.zip"
|
||||
DMG="$ROOT_DIR/dist/OpenClaw-$VERSION.dmg"
|
||||
NOTARY_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.notary.zip"
|
||||
DSYM_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.dSYM.zip"
|
||||
SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
|
||||
NOTARIZE=1
|
||||
SKIP_DSYM="${SKIP_DSYM:-0}"
|
||||
|
||||
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
|
||||
NOTARIZE=0
|
||||
@@ -54,3 +59,31 @@ if [[ "$NOTARIZE" == "1" ]]; then
|
||||
fi
|
||||
"$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$DMG"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_DSYM" != "1" ]]; then
|
||||
DSYM_ARM64="$(find "$BUILD_ROOT/arm64" -type d -path "*/$BUILD_CONFIG/$PRODUCT.dSYM" -print -quit)"
|
||||
DSYM_X86="$(find "$BUILD_ROOT/x86_64" -type d -path "*/$BUILD_CONFIG/$PRODUCT.dSYM" -print -quit)"
|
||||
if [[ -n "$DSYM_ARM64" || -n "$DSYM_X86" ]]; then
|
||||
TMP_DSYM="$ROOT_DIR/dist/$PRODUCT.dSYM"
|
||||
rm -rf "$TMP_DSYM"
|
||||
if [[ -n "$DSYM_ARM64" && -n "$DSYM_X86" ]]; then
|
||||
cp -R "$DSYM_ARM64" "$TMP_DSYM"
|
||||
DWARF_OUT="$TMP_DSYM/Contents/Resources/DWARF/$PRODUCT"
|
||||
DWARF_ARM="$DSYM_ARM64/Contents/Resources/DWARF/$PRODUCT"
|
||||
DWARF_X86="$DSYM_X86/Contents/Resources/DWARF/$PRODUCT"
|
||||
if [[ -f "$DWARF_ARM" && -f "$DWARF_X86" ]]; then
|
||||
/usr/bin/lipo -create "$DWARF_ARM" "$DWARF_X86" -output "$DWARF_OUT"
|
||||
else
|
||||
echo "WARN: Missing DWARF binaries for dSYM merge (continuing)" >&2
|
||||
fi
|
||||
else
|
||||
cp -R "${DSYM_ARM64:-$DSYM_X86}" "$TMP_DSYM"
|
||||
fi
|
||||
echo "🧩 dSYM: $DSYM_ZIP"
|
||||
rm -f "$DSYM_ZIP"
|
||||
ditto -c -k --keepParent "$TMP_DSYM" "$DSYM_ZIP"
|
||||
rm -rf "$TMP_DSYM"
|
||||
else
|
||||
echo "WARN: dSYM not found; skipping zip (set SKIP_DSYM=1 to silence)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
25
scripts/write-cli-compat.ts
Normal file
25
scripts/write-cli-compat.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const cliDir = path.join(distDir, "cli");
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(distDir)
|
||||
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");
|
||||
}
|
||||
|
||||
const target = candidates.toSorted()[0];
|
||||
const relPath = `../${target}`;
|
||||
|
||||
const contents =
|
||||
"// Legacy shim for pre-tsdown update-cli imports.\n" +
|
||||
`export { registerDaemonCli, runDaemonInstall, runDaemonRestart, runDaemonStart, runDaemonStatus, runDaemonStop, runDaemonUninstall } from "${relPath}";\n`;
|
||||
|
||||
fs.mkdirSync(cliDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(cliDir, "daemon-cli.js"), contents);
|
||||
@@ -1,44 +1,131 @@
|
||||
---
|
||||
name: bluebubbles
|
||||
description: Build or update the BlueBubbles external channel plugin for OpenClaw (extension package, REST send/probe, webhook inbound).
|
||||
description: Use when you need to send or manage iMessages via BlueBubbles (recommended iMessage integration). Calls go through the generic message tool with channel="bluebubbles".
|
||||
metadata: { "openclaw": { "emoji": "🫧", "requires": { "config": ["channels.bluebubbles"] } } }
|
||||
---
|
||||
|
||||
# BlueBubbles plugin
|
||||
# BlueBubbles Actions
|
||||
|
||||
Use this skill when working on the BlueBubbles channel plugin.
|
||||
## Overview
|
||||
|
||||
## Layout
|
||||
BlueBubbles is OpenClaw’s recommended iMessage integration. Use the `message` tool with `channel: "bluebubbles"` to send messages and manage iMessage conversations: send texts and attachments, react (tapbacks), edit/unsend, reply in threads, and manage group participants/names/icons.
|
||||
|
||||
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
||||
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
|
||||
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
|
||||
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
|
||||
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
|
||||
## Inputs to collect
|
||||
|
||||
## Internal helpers (use these, not raw API calls)
|
||||
- `target` (prefer `chat_guid:...`; also `+15551234567` in E.164 or `user@example.com`)
|
||||
- `message` text for send/edit/reply
|
||||
- `messageId` for react/edit/unsend/reply
|
||||
- Attachment `path` for local files, or `buffer` + `filename` for base64
|
||||
|
||||
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
|
||||
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
|
||||
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
|
||||
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
|
||||
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
|
||||
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
|
||||
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
|
||||
If the user is vague ("text my mom"), ask for the recipient handle or chat guid and the exact message content.
|
||||
|
||||
## Webhooks
|
||||
## Actions
|
||||
|
||||
- BlueBubbles posts JSON to the gateway HTTP server.
|
||||
- Normalize sender/chat IDs defensively (payloads vary by version).
|
||||
- Skip messages marked as from self.
|
||||
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers.
|
||||
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
|
||||
### Send a message
|
||||
|
||||
## Config (core)
|
||||
```json
|
||||
{
|
||||
"action": "send",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"message": "hello from OpenClaw"
|
||||
}
|
||||
```
|
||||
|
||||
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
|
||||
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
|
||||
### React (tapback)
|
||||
|
||||
## Message tool notes
|
||||
```json
|
||||
{
|
||||
"action": "react",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"messageId": "<message-guid>",
|
||||
"emoji": "❤️"
|
||||
}
|
||||
```
|
||||
|
||||
- **Reactions:** The `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`. Example: `action=react target=+15551234567 messageId=ABC123 emoji=❤️`
|
||||
### Remove a reaction
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "react",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"messageId": "<message-guid>",
|
||||
"emoji": "❤️",
|
||||
"remove": true
|
||||
}
|
||||
```
|
||||
|
||||
### Edit a previously sent message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "edit",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"messageId": "<message-guid>",
|
||||
"message": "updated text"
|
||||
}
|
||||
```
|
||||
|
||||
### Unsend a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "unsend",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"messageId": "<message-guid>"
|
||||
}
|
||||
```
|
||||
|
||||
### Reply to a specific message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reply",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"replyTo": "<message-guid>",
|
||||
"message": "replying to that"
|
||||
}
|
||||
```
|
||||
|
||||
### Send an attachment
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sendAttachment",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"path": "/tmp/photo.jpg",
|
||||
"caption": "here you go"
|
||||
}
|
||||
```
|
||||
|
||||
### Send with an iMessage effect
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sendWithEffect",
|
||||
"channel": "bluebubbles",
|
||||
"target": "+15551234567",
|
||||
"message": "big news",
|
||||
"effect": "balloons"
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires gateway config `channels.bluebubbles` (serverUrl/password/webhookPath).
|
||||
- Prefer `chat_guid` targets when you have them (especially for group chats).
|
||||
- BlueBubbles supports rich actions, but some are macOS-version dependent (for example, edit may be broken on macOS 26 Tahoe).
|
||||
- The gateway may expose both short and full message ids; full ids are more durable across restarts.
|
||||
- Developer reference for the underlying plugin lives in `extensions/bluebubbles/README.md`.
|
||||
|
||||
## Ideas to try
|
||||
|
||||
- React with a tapback to acknowledge a request.
|
||||
- Reply in-thread when a user references a specific message.
|
||||
- Send a file attachment with a short caption.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: discord
|
||||
description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
|
||||
description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, set bot presence/activity, or handle moderation actions in Discord DMs or channels.
|
||||
metadata: {"openclaw":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
|
||||
---
|
||||
|
||||
@@ -139,6 +139,7 @@ Use `discord.actions.*` to disable action groups:
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `channels` (channel/category create/edit/delete/move, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
- `presence` (bot status/activity, default `false`)
|
||||
|
||||
### Read recent messages
|
||||
|
||||
@@ -432,6 +433,101 @@ Create, edit, delete, and move channels and categories. Enable via `discord.acti
|
||||
}
|
||||
```
|
||||
|
||||
### Bot presence/activity (disabled by default)
|
||||
|
||||
Set the bot's online status and activity. Enable via `discord.actions.presence: true`.
|
||||
|
||||
Discord bots can only set `name`, `state`, `type`, and `url` on an activity. Other Activity fields (details, emoji, assets) are accepted by the gateway but silently ignored by Discord for bots.
|
||||
|
||||
**How fields render by activity type:**
|
||||
|
||||
- **playing, streaming, listening, watching, competing**: `activityName` is shown in the sidebar under the bot's name (e.g. "**with fire**" for type "playing" and name "with fire"). `activityState` is shown in the profile flyout.
|
||||
- **custom**: `activityName` is ignored. Only `activityState` is displayed as the status text in the sidebar.
|
||||
- **streaming**: `activityUrl` may be displayed or embedded by the client.
|
||||
|
||||
**Set playing status:**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "playing",
|
||||
"activityName": "with fire"
|
||||
}
|
||||
```
|
||||
|
||||
Result in sidebar: "**with fire**". Flyout shows: "Playing: with fire"
|
||||
|
||||
**With state (shown in flyout):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "playing",
|
||||
"activityName": "My Game",
|
||||
"activityState": "In the lobby"
|
||||
}
|
||||
```
|
||||
|
||||
Result in sidebar: "**My Game**". Flyout shows: "Playing: My Game (newline) In the lobby".
|
||||
|
||||
**Set streaming (optional URL, may not render for bots):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "streaming",
|
||||
"activityName": "Live coding",
|
||||
"activityUrl": "https://twitch.tv/example"
|
||||
}
|
||||
```
|
||||
|
||||
**Set listening/watching:**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "listening",
|
||||
"activityName": "Spotify"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "watching",
|
||||
"activityName": "the logs"
|
||||
}
|
||||
```
|
||||
|
||||
**Set a custom status (text in sidebar):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "custom",
|
||||
"activityState": "Vibing"
|
||||
}
|
||||
```
|
||||
|
||||
Result in sidebar: "Vibing". Note: `activityName` is ignored for custom type.
|
||||
|
||||
**Set bot status only (no activity/clear status):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"status": "dnd"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `activityType`: `playing`, `streaming`, `listening`, `watching`, `competing`, `custom`
|
||||
- `activityName`: text shown in the sidebar for non-custom types (ignored for `custom`)
|
||||
- `activityUrl`: Twitch or YouTube URL for streaming type (optional; may not render for bots)
|
||||
- `activityState`: for `custom` this is the status text; for other types it shows in the profile flyout
|
||||
- `status`: `online` (default), `dnd`, `idle`, `invisible`
|
||||
|
||||
## Discord Writing Style Guide
|
||||
|
||||
**Keep it conversational!** Discord is a chat platform, not documentation.
|
||||
|
||||
@@ -23,24 +23,52 @@ metadata:
|
||||
}
|
||||
---
|
||||
|
||||
# imsg
|
||||
# imsg Actions
|
||||
|
||||
## Overview
|
||||
|
||||
Use `imsg` to read and send Messages.app iMessage/SMS on macOS.
|
||||
|
||||
Requirements
|
||||
Requirements: Messages.app signed in, Full Disk Access for your terminal, and Automation permission to control Messages.app for sending.
|
||||
|
||||
- Messages.app signed in
|
||||
- Full Disk Access for your terminal
|
||||
- Automation permission to control Messages.app (for sending)
|
||||
## Inputs to collect
|
||||
|
||||
Common commands
|
||||
- Recipient handle (phone/email) for `send`
|
||||
- `chatId` for history/watch (from `imsg chats --limit 10 --json`)
|
||||
- `text` and optional `file` path for sends
|
||||
|
||||
- List chats: `imsg chats --limit 10 --json`
|
||||
- History: `imsg history --chat-id 1 --limit 20 --attachments --json`
|
||||
- Watch: `imsg watch --chat-id 1 --attachments`
|
||||
- Send: `imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg`
|
||||
## Actions
|
||||
|
||||
Notes
|
||||
### List chats
|
||||
|
||||
```bash
|
||||
imsg chats --limit 10 --json
|
||||
```
|
||||
|
||||
### Fetch chat history
|
||||
|
||||
```bash
|
||||
imsg history --chat-id 1 --limit 20 --attachments --json
|
||||
```
|
||||
|
||||
### Watch a chat
|
||||
|
||||
```bash
|
||||
imsg watch --chat-id 1 --attachments
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```bash
|
||||
imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `--service imessage|sms|auto` controls delivery.
|
||||
- Confirm recipient + message before sending.
|
||||
|
||||
## Ideas to try
|
||||
|
||||
- Use `imsg chats --limit 10 --json` to discover chat ids.
|
||||
- Watch a high-signal chat to stream incoming messages.
|
||||
|
||||
@@ -50,6 +50,15 @@ To monitor:
|
||||
|
||||
- Prefer literal sends: `tmux -S "$SOCKET" send-keys -t target -l -- "$cmd"`.
|
||||
- Control keys: `tmux -S "$SOCKET" send-keys -t target C-c`.
|
||||
- For interactive TUI apps like Claude Code/Codex, this guidance covers **how to send commands**.
|
||||
Do **not** append `Enter` in the same `send-keys`. These apps may treat a fast text+Enter
|
||||
sequence as paste/multi-line input and not submit; this is timing-dependent. Send text and
|
||||
`Enter` as separate commands with a small delay (tune per environment; increase if needed,
|
||||
or use `sleep 1` if sub-second sleeps aren't supported):
|
||||
|
||||
```bash
|
||||
tmux -S "$SOCKET" send-keys -t target -l -- "$cmd" && sleep 0.1 && tmux -S "$SOCKET" send-keys -t target Enter
|
||||
```
|
||||
|
||||
## Watching output
|
||||
|
||||
@@ -82,6 +91,9 @@ done
|
||||
tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter
|
||||
tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter
|
||||
|
||||
# When sending prompts to Claude Code/Codex TUI, split text + Enter with a delay
|
||||
tmux -S "$SOCKET" send-keys -t agent-1 -l -- "Please make a small edit to README.md." && sleep 0.1 && tmux -S "$SOCKET" send-keys -t agent-1 Enter
|
||||
|
||||
# Poll for completion (check if prompt returned)
|
||||
for sess in agent-1 agent-2; do
|
||||
if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q "❯"; then
|
||||
|
||||
@@ -53,6 +53,10 @@ export function createOpenClawTools(options?: {
|
||||
modelHasVision?: boolean;
|
||||
/** Explicit agent ID override for cron/hook sessions. */
|
||||
requesterAgentIdOverride?: string;
|
||||
/** Require explicit message targets (no implicit last-route sends). */
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
}): AnyAgentTool[] {
|
||||
const imageTool = options?.agentDir?.trim()
|
||||
? createImageTool({
|
||||
@@ -70,6 +74,20 @@ export function createOpenClawTools(options?: {
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
});
|
||||
const messageTool = options?.disableMessageTool
|
||||
? null
|
||||
: createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentChannelProvider: options?.agentChannel,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
sandboxRoot: options?.sandboxRoot,
|
||||
requireExplicitTarget: options?.requireExplicitMessageTarget,
|
||||
});
|
||||
const tools: AnyAgentTool[] = [
|
||||
createBrowserTool({
|
||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||
@@ -83,17 +101,7 @@ export function createOpenClawTools(options?: {
|
||||
createCronTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
}),
|
||||
createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentChannelProvider: options?.agentChannel,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
sandboxRoot: options?.sandboxRoot,
|
||||
}),
|
||||
...(messageTool ? [messageTool] : []),
|
||||
createTtsTool({
|
||||
agentChannel: options?.agentChannel,
|
||||
config: options?.config,
|
||||
|
||||
@@ -238,6 +238,9 @@ export async function runEmbeddedAttempt(
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
modelHasVision,
|
||||
requireExplicitMessageTarget:
|
||||
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
});
|
||||
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
|
||||
logToolSchemasForGoogle({ tools, provider: params.provider });
|
||||
|
||||
@@ -47,6 +47,10 @@ export type RunEmbeddedPiAgentParams = {
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/** Require explicit message tool targets (no implicit last-route sends). */
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
|
||||
@@ -78,6 +78,10 @@ export type EmbeddedRunAttemptParams = {
|
||||
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
/** Require explicit message tool targets (no implicit last-route sends). */
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
extraSystemPrompt?: string;
|
||||
streamParams?: AgentStreamParams;
|
||||
ownerNumbers?: string[];
|
||||
|
||||
@@ -157,6 +157,10 @@ export function createOpenClawCodingTools(options?: {
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/** If true, the model has native vision capability */
|
||||
modelHasVision?: boolean;
|
||||
/** Require explicit message targets (no implicit last-route sends). */
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
}): AnyAgentTool[] {
|
||||
const execToolName = "exec";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
@@ -348,6 +352,8 @@ export function createOpenClawCodingTools(options?: {
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
modelHasVision: options?.modelHasVision,
|
||||
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
|
||||
disableMessageTool: options?.disableMessageTool,
|
||||
requesterAgentIdOverride: agentId,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -323,10 +323,10 @@ export function buildSubagentSystemPrompt(params: {
|
||||
"",
|
||||
"## What You DON'T Do",
|
||||
"- NO user conversations (that's main agent's job)",
|
||||
"- NO external messages (email, tweets, etc.) unless explicitly tasked",
|
||||
"- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel",
|
||||
"- NO cron jobs or persistent state",
|
||||
"- NO pretending to be the main agent",
|
||||
"- NO using the `message` tool directly",
|
||||
"- Only use the `message` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the main agent deliver it",
|
||||
"",
|
||||
"## Session Context",
|
||||
params.label ? `- Label: ${params.label}` : undefined,
|
||||
|
||||
@@ -82,7 +82,9 @@ describe("cron tool", () => {
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params).toEqual({
|
||||
name: "wake-up",
|
||||
schedule: { kind: "at", atMs: 123 },
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
schedule: { kind: "at", at: new Date(123).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
@@ -95,7 +97,7 @@ describe("cron tool", () => {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "wake-up",
|
||||
schedule: { atMs: 123 },
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
agentId: null,
|
||||
},
|
||||
});
|
||||
@@ -126,7 +128,7 @@ describe("cron tool", () => {
|
||||
contextMessages: 3,
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { atMs: 123 },
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||
},
|
||||
});
|
||||
@@ -163,7 +165,7 @@ describe("cron tool", () => {
|
||||
contextMessages: 20,
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { atMs: 123 },
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||
},
|
||||
});
|
||||
@@ -194,7 +196,7 @@ describe("cron tool", () => {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { atMs: 123 },
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { text: "Reminder: the thing." },
|
||||
},
|
||||
});
|
||||
@@ -218,7 +220,7 @@ describe("cron tool", () => {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { atMs: 123 },
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
agentId: null,
|
||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||
},
|
||||
|
||||
@@ -174,27 +174,36 @@ JOB SCHEMA (for add action):
|
||||
"name": "string (optional)",
|
||||
"schedule": { ... }, // Required: when to run
|
||||
"payload": { ... }, // Required: what to execute
|
||||
"delivery": { ... }, // Optional: announce summary (isolated only)
|
||||
"sessionTarget": "main" | "isolated", // Required
|
||||
"enabled": true | false // Optional, default true
|
||||
}
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
- "at": One-shot at absolute time
|
||||
{ "kind": "at", "atMs": <unix-ms-timestamp> }
|
||||
{ "kind": "at", "at": "<ISO-8601 timestamp>" }
|
||||
- "every": Recurring interval
|
||||
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
|
||||
- "cron": Cron expression
|
||||
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
|
||||
|
||||
ISO timestamps without an explicit timezone are treated as UTC.
|
||||
|
||||
PAYLOAD TYPES (payload.kind):
|
||||
- "systemEvent": Injects text as system event into session
|
||||
{ "kind": "systemEvent", "text": "<message>" }
|
||||
- "agentTurn": Runs agent with message (isolated sessions only)
|
||||
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
|
||||
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
|
||||
|
||||
DELIVERY (isolated-only, top-level):
|
||||
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
|
||||
- If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run.
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
||||
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
|
||||
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
|
||||
|
||||
WAKE MODES (for wake action):
|
||||
- "next-heartbeat" (default): Wake on next heartbeat
|
||||
@@ -208,7 +217,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
const gatewayOpts: GatewayCallOptions = {
|
||||
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
|
||||
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
|
||||
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 60_000,
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
|
||||
210
src/agents/tools/discord-actions-presence.test.ts
Normal file
210
src/agents/tools/discord-actions-presence.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import type { ActionGate } from "./common.js";
|
||||
import { clearGateways, registerGateway } from "../../discord/monitor/gateway-registry.js";
|
||||
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
|
||||
|
||||
const mockUpdatePresence = vi.fn();
|
||||
|
||||
function createMockGateway(connected = true): GatewayPlugin {
|
||||
return { isConnected: connected, updatePresence: mockUpdatePresence } as unknown as GatewayPlugin;
|
||||
}
|
||||
|
||||
const presenceEnabled: ActionGate<DiscordActionConfig> = (key) => key === "presence";
|
||||
const presenceDisabled: ActionGate<DiscordActionConfig> = () => false;
|
||||
|
||||
describe("handleDiscordPresenceAction", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdatePresence.mockClear();
|
||||
clearGateways();
|
||||
registerGateway(undefined, createMockGateway());
|
||||
});
|
||||
|
||||
it("sets playing activity", async () => {
|
||||
const result = await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "playing", activityName: "with fire", status: "online" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "with fire", type: 0 }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
const payload = JSON.parse(result.content[0].text ?? "");
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.activities[0]).toEqual({ type: 0, name: "with fire" });
|
||||
});
|
||||
|
||||
it("sets streaming activity with optional URL", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{
|
||||
activityType: "streaming",
|
||||
activityName: "My Stream",
|
||||
activityUrl: "https://twitch.tv/example",
|
||||
},
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "My Stream", type: 1, url: "https://twitch.tv/example" }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows streaming without URL", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "streaming", activityName: "My Stream" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "My Stream", type: 1 }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets listening activity", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "listening", activityName: "Spotify" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activities: [{ name: "Spotify", type: 2 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets watching activity", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "watching", activityName: "you" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activities: [{ name: "you", type: 3 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets custom activity using state", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "custom", activityState: "Vibing" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "", type: 4, state: "Vibing" }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes activityState", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "playing", activityName: "My Game", activityState: "In the lobby" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "My Game", type: 0, state: "In the lobby" }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets status-only without activity", async () => {
|
||||
await handleDiscordPresenceAction("setPresence", { status: "idle" }, presenceEnabled);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [],
|
||||
status: "idle",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults status to online", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "playing", activityName: "test" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(expect.objectContaining({ status: "online" }));
|
||||
});
|
||||
|
||||
it("rejects invalid status", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "offline" }, presenceEnabled),
|
||||
).rejects.toThrow(/Invalid status/);
|
||||
});
|
||||
|
||||
it("rejects invalid activity type", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { activityType: "invalid" }, presenceEnabled),
|
||||
).rejects.toThrow(/Invalid activityType/);
|
||||
});
|
||||
|
||||
it("respects presence gating", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "online" }, presenceDisabled),
|
||||
).rejects.toThrow(/disabled/);
|
||||
});
|
||||
|
||||
it("errors when gateway is not registered", async () => {
|
||||
clearGateways();
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "dnd" }, presenceEnabled),
|
||||
).rejects.toThrow(/not available/);
|
||||
});
|
||||
|
||||
it("errors when gateway is not connected", async () => {
|
||||
clearGateways();
|
||||
registerGateway(undefined, createMockGateway(false));
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "dnd" }, presenceEnabled),
|
||||
).rejects.toThrow(/not connected/);
|
||||
});
|
||||
|
||||
it("uses accountId to resolve gateway", async () => {
|
||||
const accountGateway = createMockGateway();
|
||||
registerGateway("my-account", accountGateway);
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ accountId: "my-account", activityType: "playing", activityName: "test" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults activity name to empty string when only type is provided", async () => {
|
||||
await handleDiscordPresenceAction("setPresence", { activityType: "playing" }, presenceEnabled);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activities: [{ name: "", type: 0 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires activityType when activityName is provided", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { activityName: "My Game" }, presenceEnabled),
|
||||
).rejects.toThrow(/activityType is required/);
|
||||
});
|
||||
|
||||
it("rejects unknown presence actions", async () => {
|
||||
await expect(handleDiscordPresenceAction("unknownAction", {}, presenceEnabled)).rejects.toThrow(
|
||||
/Unknown presence action/,
|
||||
);
|
||||
});
|
||||
});
|
||||
111
src/agents/tools/discord-actions-presence.ts
Normal file
111
src/agents/tools/discord-actions-presence.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getGateway } from "../../discord/monitor/gateway-registry.js";
|
||||
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||
|
||||
const ACTIVITY_TYPE_MAP: Record<string, number> = {
|
||||
playing: 0,
|
||||
streaming: 1,
|
||||
listening: 2,
|
||||
watching: 3,
|
||||
custom: 4,
|
||||
competing: 5,
|
||||
};
|
||||
|
||||
const VALID_STATUSES = new Set(["online", "dnd", "idle", "invisible"]);
|
||||
|
||||
export async function handleDiscordPresenceAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
if (action !== "setPresence") {
|
||||
throw new Error(`Unknown presence action: ${action}`);
|
||||
}
|
||||
|
||||
if (!isActionEnabled("presence", false)) {
|
||||
throw new Error("Discord presence changes are disabled.");
|
||||
}
|
||||
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const gateway = getGateway(accountId);
|
||||
if (!gateway) {
|
||||
throw new Error(
|
||||
`Discord gateway not available${accountId ? ` for account "${accountId}"` : ""}. The bot may not be connected.`,
|
||||
);
|
||||
}
|
||||
if (!gateway.isConnected) {
|
||||
throw new Error(
|
||||
`Discord gateway is not connected${accountId ? ` for account "${accountId}"` : ""}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const statusRaw = readStringParam(params, "status") ?? "online";
|
||||
if (!VALID_STATUSES.has(statusRaw)) {
|
||||
throw new Error(
|
||||
`Invalid status "${statusRaw}". Must be one of: ${[...VALID_STATUSES].join(", ")}`,
|
||||
);
|
||||
}
|
||||
const status = statusRaw as UpdatePresenceData["status"];
|
||||
|
||||
const activityTypeRaw = readStringParam(params, "activityType");
|
||||
const activityName = readStringParam(params, "activityName");
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
if (activityTypeRaw || activityName) {
|
||||
if (!activityTypeRaw) {
|
||||
throw new Error(
|
||||
"activityType is required when activityName is provided. " +
|
||||
`Valid types: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
|
||||
);
|
||||
}
|
||||
const typeNum = ACTIVITY_TYPE_MAP[activityTypeRaw.toLowerCase()];
|
||||
if (typeNum === undefined) {
|
||||
throw new Error(
|
||||
`Invalid activityType "${activityTypeRaw}". Must be one of: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const activity: Activity = {
|
||||
name: activityName ?? "",
|
||||
type: typeNum,
|
||||
};
|
||||
|
||||
// Streaming URL (Twitch/YouTube). May not render for bots but is the correct payload shape.
|
||||
if (typeNum === 1) {
|
||||
const url = readStringParam(params, "activityUrl");
|
||||
if (url) {
|
||||
activity.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
const state = readStringParam(params, "activityState");
|
||||
if (state) {
|
||||
activity.state = state;
|
||||
}
|
||||
|
||||
activities.push(activity);
|
||||
}
|
||||
|
||||
const presenceData: UpdatePresenceData = {
|
||||
since: null,
|
||||
activities,
|
||||
status,
|
||||
afk: false,
|
||||
};
|
||||
|
||||
gateway.updatePresence(presenceData);
|
||||
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
status,
|
||||
activities: activities.map((a) => ({
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
...(a.url ? { url: a.url } : {}),
|
||||
...(a.state ? { state: a.state } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createActionGate, readStringParam } from "./common.js";
|
||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
|
||||
|
||||
const messagingActions = new Set([
|
||||
"react",
|
||||
@@ -51,6 +52,8 @@ const guildActions = new Set([
|
||||
|
||||
const moderationActions = new Set(["timeout", "kick", "ban"]);
|
||||
|
||||
const presenceActions = new Set(["setPresence"]);
|
||||
|
||||
export async function handleDiscordAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
@@ -67,5 +70,8 @@ export async function handleDiscordAction(
|
||||
if (moderationActions.has(action)) {
|
||||
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
||||
}
|
||||
if (presenceActions.has(action)) {
|
||||
return await handleDiscordPresenceAction(action, params, isActionEnabled);
|
||||
}
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function resolveGatewayOptions(opts?: GatewayCallOptions) {
|
||||
const timeoutMs =
|
||||
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
||||
? Math.max(1, Math.floor(opts.timeoutMs))
|
||||
: 10_000;
|
||||
: 30_000;
|
||||
return { url, token, timeoutMs };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,18 @@ import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
|
||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||
const EXPLICIT_TARGET_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"send",
|
||||
"sendWithEffect",
|
||||
"sendAttachment",
|
||||
"reply",
|
||||
"thread-reply",
|
||||
"broadcast",
|
||||
]);
|
||||
|
||||
function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean {
|
||||
return EXPLICIT_TARGET_ACTIONS.has(action);
|
||||
}
|
||||
function buildRoutingSchema() {
|
||||
return {
|
||||
channel: Type.Optional(Type.String()),
|
||||
@@ -193,6 +205,36 @@ function buildGatewaySchema() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPresenceSchema() {
|
||||
return {
|
||||
activityType: Type.Optional(
|
||||
Type.String({
|
||||
description: "Activity type: playing, streaming, listening, watching, competing, custom.",
|
||||
}),
|
||||
),
|
||||
activityName: Type.Optional(
|
||||
Type.String({
|
||||
description: "Activity name shown in sidebar (e.g. 'with fire'). Ignored for custom type.",
|
||||
}),
|
||||
),
|
||||
activityUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Streaming URL (Twitch or YouTube). Only used with streaming type; may not render for bots.",
|
||||
}),
|
||||
),
|
||||
activityState: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"State text. For custom type this is the status text; for others it shows in the flyout.",
|
||||
}),
|
||||
),
|
||||
status: Type.Optional(
|
||||
Type.String({ description: "Bot status: online, dnd, idle, invisible." }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildChannelManagementSchema() {
|
||||
return {
|
||||
name: Type.Optional(Type.String()),
|
||||
@@ -225,6 +267,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
|
||||
...buildModerationSchema(),
|
||||
...buildGatewaySchema(),
|
||||
...buildChannelManagementSchema(),
|
||||
...buildPresenceSchema(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +297,7 @@ type MessageToolOptions = {
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sandboxRoot?: string;
|
||||
requireExplicitTarget?: boolean;
|
||||
};
|
||||
|
||||
function buildMessageToolSchema(cfg: OpenClawConfig) {
|
||||
@@ -363,6 +407,20 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
const action = readStringParam(params, "action", {
|
||||
required: true,
|
||||
}) as ChannelMessageActionName;
|
||||
const requireExplicitTarget = options?.requireExplicitTarget === true;
|
||||
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
|
||||
const explicitTarget =
|
||||
(typeof params.target === "string" && params.target.trim().length > 0) ||
|
||||
(typeof params.to === "string" && params.to.trim().length > 0) ||
|
||||
(typeof params.channelId === "string" && params.channelId.trim().length > 0) ||
|
||||
(Array.isArray(params.targets) &&
|
||||
params.targets.some((value) => typeof value === "string" && value.trim().length > 0));
|
||||
if (!explicitTarget) {
|
||||
throw new Error(
|
||||
"Explicit message target required for this run. Provide target/targets (and channel when needed).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file paths against sandbox root to prevent host file access.
|
||||
const sandboxRoot = options?.sandboxRoot;
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
normalizeProviderId,
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
|
||||
import { buildStatusMessage } from "../../auto-reply/status.js";
|
||||
@@ -39,7 +23,23 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import { resolveAgentDir } from "../agent-scope.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../auth-profiles.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../model-auth.js";
|
||||
import { loadModelCatalog } from "../model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
normalizeProviderId,
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../model-selection.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
import {
|
||||
shouldResolveSessionIdInput,
|
||||
|
||||
@@ -95,6 +95,17 @@ function resolveFetchReadabilityEnabled(fetch?: WebFetchConfig): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number {
|
||||
const raw =
|
||||
fetch && "maxCharsCap" in fetch && typeof fetch.maxCharsCap === "number"
|
||||
? fetch.maxCharsCap
|
||||
: undefined;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return DEFAULT_FETCH_MAX_CHARS;
|
||||
}
|
||||
return Math.max(100, Math.floor(raw));
|
||||
}
|
||||
|
||||
function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig {
|
||||
if (!fetch || typeof fetch !== "object") {
|
||||
return undefined;
|
||||
@@ -160,9 +171,10 @@ function resolveFirecrawlMaxAgeMsOrDefault(firecrawl?: FirecrawlFetchConfig): nu
|
||||
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
function resolveMaxChars(value: unknown, fallback: number): number {
|
||||
function resolveMaxChars(value: unknown, fallback: number, cap: number): number {
|
||||
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
return Math.max(100, Math.floor(parsed));
|
||||
const clamped = Math.max(100, Math.floor(parsed));
|
||||
return Math.min(clamped, cap);
|
||||
}
|
||||
|
||||
function resolveMaxRedirects(value: unknown, fallback: number): number {
|
||||
@@ -647,10 +659,15 @@ export function createWebFetchTool(options?: {
|
||||
const url = readStringParam(params, "url", { required: true });
|
||||
const extractMode = readStringParam(params, "extractMode") === "text" ? "text" : "markdown";
|
||||
const maxChars = readNumberParam(params, "maxChars", { integer: true });
|
||||
const maxCharsCap = resolveFetchMaxCharsCap(fetch);
|
||||
const result = await runWebFetch({
|
||||
url,
|
||||
extractMode,
|
||||
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
|
||||
maxChars: resolveMaxChars(
|
||||
maxChars ?? fetch?.maxChars,
|
||||
DEFAULT_FETCH_MAX_CHARS,
|
||||
maxCharsCap,
|
||||
),
|
||||
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
|
||||
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||
|
||||
@@ -49,6 +49,20 @@ function firecrawlError(): MockResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function textResponse(
|
||||
text: string,
|
||||
url = "https://example.com/",
|
||||
contentType = "text/plain; charset=utf-8",
|
||||
): MockResponse {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
url,
|
||||
headers: makeHeaders({ "content-type": contentType }),
|
||||
text: async () => text,
|
||||
};
|
||||
}
|
||||
|
||||
function errorHtmlResponse(
|
||||
html: string,
|
||||
status = 404,
|
||||
@@ -322,6 +336,37 @@ describe("web_fetch extraction fallbacks", () => {
|
||||
expect(details.extractor).toBe("firecrawl");
|
||||
expect(details.text).toContain("firecrawl fallback");
|
||||
});
|
||||
|
||||
it("wraps external content and clamps oversized maxChars", async () => {
|
||||
const large = "a".repeat(80_000);
|
||||
const mockFetch = vi.fn(
|
||||
(input: RequestInfo) =>
|
||||
Promise.resolve(textResponse(large, requestUrl(input))) as Promise<Response>,
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxCharsCap: 10_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call", {
|
||||
url: "https://example.com/large",
|
||||
maxChars: 200_000,
|
||||
});
|
||||
const details = result?.details as { text?: string; length?: number; truncated?: boolean };
|
||||
expect(details.text).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT>>>");
|
||||
expect(details.text).toContain("Source: Web Fetch");
|
||||
expect(details.length).toBeLessThanOrEqual(10_000);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
it("strips and truncates HTML from error responses", async () => {
|
||||
const long = "x".repeat(12_000);
|
||||
const html =
|
||||
|
||||
@@ -10,10 +10,112 @@ import {
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import {
|
||||
buildModelsKeyboard,
|
||||
buildProviderKeyboard,
|
||||
calculateTotalPages,
|
||||
getModelsPageSize,
|
||||
type ProviderInfo,
|
||||
} from "../../telegram/model-buttons.js";
|
||||
|
||||
const PAGE_SIZE_DEFAULT = 20;
|
||||
const PAGE_SIZE_MAX = 100;
|
||||
|
||||
export type ModelsProviderData = {
|
||||
byProvider: Map<string, Set<string>>;
|
||||
providers: string[];
|
||||
resolvedDefault: { provider: string; model: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Build provider/model data from config and catalog.
|
||||
* Exported for reuse by callback handlers.
|
||||
*/
|
||||
export async function buildModelsProviderData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
const catalog = await loadModelCatalog({ config: cfg });
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const add = (p: string, m: string) => {
|
||||
const key = normalizeProviderId(p);
|
||||
const set = byProvider.get(key) ?? new Set<string>();
|
||||
set.add(m);
|
||||
byProvider.set(key, set);
|
||||
};
|
||||
|
||||
const addRawModelRef = (raw?: string) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
add(resolved.ref.provider, resolved.ref.model);
|
||||
};
|
||||
|
||||
const addModelConfigEntries = () => {
|
||||
const modelConfig = cfg.agents?.defaults?.model;
|
||||
if (typeof modelConfig === "string") {
|
||||
addRawModelRef(modelConfig);
|
||||
} else if (modelConfig && typeof modelConfig === "object") {
|
||||
addRawModelRef(modelConfig.primary);
|
||||
for (const fallback of modelConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
const imageConfig = cfg.agents?.defaults?.imageModel;
|
||||
if (typeof imageConfig === "string") {
|
||||
addRawModelRef(imageConfig);
|
||||
} else if (imageConfig && typeof imageConfig === "object") {
|
||||
addRawModelRef(imageConfig.primary);
|
||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of allowed.allowedCatalog) {
|
||||
add(entry.provider, entry.id);
|
||||
}
|
||||
|
||||
// Include config-only allowlist keys that aren't in the curated catalog.
|
||||
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {
|
||||
addRawModelRef(raw);
|
||||
}
|
||||
|
||||
// Ensure configured defaults/fallbacks/image models show up even when the
|
||||
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
||||
add(resolvedDefault.provider, resolvedDefault.model);
|
||||
addModelConfigEntries();
|
||||
|
||||
const providers = [...byProvider.keys()].toSorted();
|
||||
|
||||
return { byProvider, providers, resolvedDefault };
|
||||
}
|
||||
|
||||
function formatProviderLine(params: { provider: string; count: number }): string {
|
||||
return `- ${params.provider} (${params.count})`;
|
||||
}
|
||||
@@ -78,6 +180,8 @@ function parseModelsArgs(raw: string): {
|
||||
export async function resolveModelsCommandReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
commandBodyNormalized: string;
|
||||
surface?: string;
|
||||
currentModel?: string;
|
||||
}): Promise<ReplyPayload | null> {
|
||||
const body = params.commandBodyNormalized.trim();
|
||||
if (!body.startsWith("/models")) {
|
||||
@@ -87,88 +191,26 @@ export async function resolveModelsCommandReply(params: {
|
||||
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||
const { provider, page, pageSize, all } = parseModelsArgs(argText);
|
||||
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg: params.cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const add = (p: string, m: string) => {
|
||||
const key = normalizeProviderId(p);
|
||||
const set = byProvider.get(key) ?? new Set<string>();
|
||||
set.add(m);
|
||||
byProvider.set(key, set);
|
||||
};
|
||||
|
||||
const addRawModelRef = (raw?: string) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
add(resolved.ref.provider, resolved.ref.model);
|
||||
};
|
||||
|
||||
const addModelConfigEntries = () => {
|
||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||
if (typeof modelConfig === "string") {
|
||||
addRawModelRef(modelConfig);
|
||||
} else if (modelConfig && typeof modelConfig === "object") {
|
||||
addRawModelRef(modelConfig.primary);
|
||||
for (const fallback of modelConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
||||
if (typeof imageConfig === "string") {
|
||||
addRawModelRef(imageConfig);
|
||||
} else if (imageConfig && typeof imageConfig === "object") {
|
||||
addRawModelRef(imageConfig.primary);
|
||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of allowed.allowedCatalog) {
|
||||
add(entry.provider, entry.id);
|
||||
}
|
||||
|
||||
// Include config-only allowlist keys that aren't in the curated catalog.
|
||||
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
||||
addRawModelRef(raw);
|
||||
}
|
||||
|
||||
// Ensure configured defaults/fallbacks/image models show up even when the
|
||||
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
||||
add(resolvedDefault.provider, resolvedDefault.model);
|
||||
addModelConfigEntries();
|
||||
|
||||
const providers = [...byProvider.keys()].toSorted();
|
||||
const { byProvider, providers } = await buildModelsProviderData(params.cfg);
|
||||
const isTelegram = params.surface === "telegram";
|
||||
|
||||
// Provider list (no provider specified)
|
||||
if (!provider) {
|
||||
// For Telegram: show buttons if there are providers
|
||||
if (isTelegram && providers.length > 0) {
|
||||
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
||||
id: p,
|
||||
count: byProvider.get(p)?.size ?? 0,
|
||||
}));
|
||||
const buttons = buildProviderKeyboard(providerInfos);
|
||||
const text = "Select a provider:";
|
||||
return {
|
||||
text,
|
||||
channelData: { telegram: { buttons } },
|
||||
};
|
||||
}
|
||||
|
||||
// Text fallback for non-Telegram surfaces
|
||||
const lines: string[] = [
|
||||
"Providers:",
|
||||
...providers.map((p) =>
|
||||
@@ -206,6 +248,29 @@ export async function resolveModelsCommandReply(params: {
|
||||
return { text: lines.join("\n") };
|
||||
}
|
||||
|
||||
// For Telegram: use button-based model list with inline keyboard pagination
|
||||
if (isTelegram) {
|
||||
const telegramPageSize = getModelsPageSize();
|
||||
const totalPages = calculateTotalPages(total, telegramPageSize);
|
||||
const safePage = Math.max(1, Math.min(page, totalPages));
|
||||
|
||||
const buttons = buildModelsKeyboard({
|
||||
provider,
|
||||
models,
|
||||
currentModel: params.currentModel,
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
pageSize: telegramPageSize,
|
||||
});
|
||||
|
||||
const text = `Models (${provider}) — ${total} available`;
|
||||
return {
|
||||
text,
|
||||
channelData: { telegram: { buttons } },
|
||||
};
|
||||
}
|
||||
|
||||
// Text fallback for non-Telegram surfaces
|
||||
const effectivePageSize = all ? total : pageSize;
|
||||
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
|
||||
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
|
||||
@@ -251,6 +316,8 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
||||
const reply = await resolveModelsCommandReply({
|
||||
cfg: params.cfg,
|
||||
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||
surface: params.ctx.Surface,
|
||||
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
|
||||
});
|
||||
if (!reply) {
|
||||
return null;
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("/models command", () => {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => {
|
||||
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
|
||||
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
@@ -162,8 +162,20 @@ describe("/models command", () => {
|
||||
expect(result.reply?.text).toContain("Use: /models <provider>");
|
||||
});
|
||||
|
||||
it("lists providers on telegram (buttons)", async () => {
|
||||
const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toBe("Select a provider:");
|
||||
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
|
||||
?.telegram?.buttons;
|
||||
expect(buttons).toBeDefined();
|
||||
expect(buttons?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists provider models with pagination hints", async () => {
|
||||
const params = buildParams("/models anthropic", cfg);
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildParams("/models anthropic", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
@@ -174,7 +186,8 @@ describe("/models command", () => {
|
||||
});
|
||||
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
const params = buildParams("/models anthropic 3 all", cfg);
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
@@ -184,7 +197,8 @@ describe("/models command", () => {
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
const params = buildParams("/models anthropic 4", cfg);
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Page out of range");
|
||||
@@ -213,11 +227,16 @@ describe("/models command", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const providerList = await handleCommands(buildParams("/models", customCfg));
|
||||
// Use discord surface for text-based output tests
|
||||
const providerList = await handleCommands(
|
||||
buildParams("/models", customCfg, { Surface: "discord" }),
|
||||
);
|
||||
expect(providerList.reply?.text).toContain("localai");
|
||||
expect(providerList.reply?.text).toContain("visionpro");
|
||||
|
||||
const result = await handleCommands(buildParams("/models localai", customCfg));
|
||||
const result = await handleCommands(
|
||||
buildParams("/models localai", customCfg, { Surface: "discord" }),
|
||||
);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (localai)");
|
||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function handleDirectiveOnly(params: {
|
||||
currentVerboseLevel?: VerboseLevel;
|
||||
currentReasoningLevel?: ReasoningLevel;
|
||||
currentElevatedLevel?: ElevatedLevel;
|
||||
surface?: string;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
directives,
|
||||
@@ -132,6 +133,7 @@ export async function handleDirectiveOnly(params: {
|
||||
aliasIndex,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride,
|
||||
surface: params.surface,
|
||||
});
|
||||
if (modelInfo) {
|
||||
return modelInfo;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { resolveModelsCommandReply } from "./commands-models.js";
|
||||
import {
|
||||
@@ -177,6 +178,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
|
||||
resetModelOverride: boolean;
|
||||
surface?: string;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
if (!params.directives.hasModelDirective) {
|
||||
return undefined;
|
||||
@@ -213,6 +215,22 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
|
||||
if (wantsSummary) {
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const isTelegram = params.surface === "telegram";
|
||||
|
||||
if (isTelegram) {
|
||||
const buttons = buildBrowseProvidersButton();
|
||||
return {
|
||||
text: [
|
||||
`Current: ${current}`,
|
||||
"",
|
||||
"Tap below to browse models, or use:",
|
||||
"/model <provider/model> to switch",
|
||||
"/model status for details",
|
||||
].join("\n"),
|
||||
channelData: { telegram: { buttons } },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: [
|
||||
`Current: ${current}`,
|
||||
|
||||
@@ -183,6 +183,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
currentVerboseLevel,
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
surface: ctx.Surface,
|
||||
});
|
||||
let statusReply: ReplyPayload | undefined;
|
||||
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { resolveQueueSettings } from "./queue.js";
|
||||
import { routeReply } from "./route-reply.js";
|
||||
import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js";
|
||||
import { resolveTypingMode } from "./typing-mode.js";
|
||||
import { appendUntrustedContext } from "./untrusted-context.js";
|
||||
|
||||
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
|
||||
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
@@ -227,6 +228,7 @@ export async function runPreparedReply(
|
||||
isNewSession,
|
||||
prefixedBodyBase,
|
||||
});
|
||||
prefixedBodyBase = appendUntrustedContext(prefixedBodyBase, sessionCtx.UntrustedContext);
|
||||
const threadStarterBody = ctx.ThreadStarterBody?.trim();
|
||||
const threadStarterNote =
|
||||
isNewSession && threadStarterBody
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user