Compare commits

..

3 Commits

Author SHA1 Message Date
Vincent Koc
db3f25ae75 Device pairing: cover constrained setup code verification 2026-03-14 23:19:20 -07:00
Vincent Koc
7b3630e310 Changelog: note setup code node binding 2026-03-14 20:17:39 -07:00
Vincent Koc
c71fb8cda0 Device pairing: bind setup codes to node approvals 2026-03-14 20:12:27 -07:00
227 changed files with 2547 additions and 17969 deletions

View File

@@ -1,8 +1,8 @@
---
description: Update OpenClaw from upstream when branch has diverged (ahead/behind)
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
---
# OpenClaw Upstream Sync Workflow
# Clawdbot Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
@@ -132,16 +132,16 @@ pnpm mac:package
```bash
# Kill running app
pkill -x "OpenClaw" || true
pkill -x "Clawdbot" || true
# Move old version
mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
# Install new build
cp -R dist/OpenClaw.app /Applications/
cp -R dist/Clawdbot.app /Applications/
# Launch
open /Applications/OpenClaw.app
open /Applications/Clawdbot.app
```
---
@@ -235,7 +235,7 @@ If upstream introduced new model configurations:
# Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update openclaw.json with fallback chains
# Update clawdbot.json with fallback chains
# Add model fallback configurations as needed
```

View File

@@ -6,63 +6,33 @@ Docs: https://docs.openclaw.ai
### Changes
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
### Fixes
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
### Fixes
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
## 2026.3.13

View File

@@ -2,8 +2,8 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.svg" alt="OpenClaw" width="500">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
</picture>
</p>

View File

@@ -19,7 +19,6 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />

View File

@@ -110,10 +110,6 @@ class NodeRuntime(context: Context) {
appContext = appContext,
)
private val callLogHandler: CallLogHandler = CallLogHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext,
)
@@ -155,7 +151,6 @@ class NodeRuntime(context: Context) {
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },

View File

@@ -1,247 +0,0 @@
package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.provider.CallLog
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.put
private const val DEFAULT_CALL_LOG_LIMIT = 25
internal data class CallLogRecord(
val number: String?,
val cachedName: String?,
val date: Long,
val duration: Long,
val type: Int,
)
internal data class CallLogSearchRequest(
val limit: Int, // Number of records to return
val offset: Int, // Offset value
val cachedName: String?, // Search by contact name
val number: String?, // Search by phone number
val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
val dateStart: Long?, // Query start time (timestamp)
val dateEnd: Long?, // Query end time (timestamp)
val duration: Long?, // Search by duration (seconds)
val type: Int?, // Search by call log type
)
internal interface CallLogDataSource {
fun hasReadPermission(context: Context): Boolean
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
}
private object SystemCallLogDataSource : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CALL_LOG
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val resolver = context.contentResolver
val projection = arrayOf(
CallLog.Calls.NUMBER,
CallLog.Calls.CACHED_NAME,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.TYPE,
)
// Build selection and selectionArgs for filtering
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
request.cachedName?.let {
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
selectionArgs.add("%$it%")
}
request.number?.let {
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
selectionArgs.add("%$it%")
}
// Support time range query
if (request.dateStart != null && request.dateEnd != null) {
selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
selectionArgs.add(request.dateStart.toString())
selectionArgs.add(request.dateEnd.toString())
} else if (request.dateStart != null) {
selections.add("${CallLog.Calls.DATE} >= ?")
selectionArgs.add(request.dateStart.toString())
} else if (request.dateEnd != null) {
selections.add("${CallLog.Calls.DATE} <= ?")
selectionArgs.add(request.dateEnd.toString())
} else if (request.date != null) {
// Compatible with the old date parameter (exact match)
selections.add("${CallLog.Calls.DATE} = ?")
selectionArgs.add(request.date.toString())
}
request.duration?.let {
selections.add("${CallLog.Calls.DURATION} = ?")
selectionArgs.add(it.toString())
}
request.type?.let {
selections.add("${CallLog.Calls.TYPE} = ?")
selectionArgs.add(it.toString())
}
val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
val sortOrder = "${CallLog.Calls.DATE} DESC"
resolver.query(
CallLog.Calls.CONTENT_URI,
projection,
selection,
selectionArgsArray,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
// Skip offset rows
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
// Successfully moved to offset position
}
val out = mutableListOf<CallLogRecord>()
var count = 0
while (cursor.moveToNext() && count < request.limit) {
out += CallLogRecord(
number = cursor.getString(numberIndex),
cachedName = cursor.getString(cachedNameIndex),
date = cursor.getLong(dateIndex),
duration = cursor.getLong(durationIndex),
type = cursor.getInt(typeIndex),
)
count++
}
return out
}
}
}
class CallLogHandler private constructor(
private val appContext: Context,
private val dataSource: CallLogDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALL_LOG_PERMISSION_REQUIRED",
message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
)
}
val request = parseSearchRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val callLogs = dataSource.search(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"callLogs",
buildJsonArray {
callLogs.forEach { add(callLogJson(it)) }
},
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALL_LOG_UNAVAILABLE",
message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
)
}
}
private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
if (paramsJson.isNullOrBlank()) {
return CallLogSearchRequest(
limit = DEFAULT_CALL_LOG_LIMIT,
offset = 0,
cachedName = null,
number = null,
date = null,
dateStart = null,
dateEnd = null,
duration = null,
type = null,
)
}
val params = try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
.coerceIn(1, 200)
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
return CallLogSearchRequest(
limit = limit,
offset = offset,
cachedName = cachedName,
number = number,
date = date,
dateStart = dateStart,
dateEnd = dateEnd,
duration = duration,
type = type,
)
}
private fun callLogJson(callLog: CallLogRecord): JsonObject {
return buildJsonObject {
put("number", JsonPrimitive(callLog.number))
put("cachedName", JsonPrimitive(callLog.cachedName))
put("date", JsonPrimitive(callLog.date))
put("duration", JsonPrimitive(callLog.duration))
put("type", JsonPrimitive(callLog.type))
}
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: CallLogDataSource,
): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@@ -212,13 +212,6 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
put(
"callLog",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = true,
),
)
put(
"motion",
permissionStateJson(

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -85,7 +84,6 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable,
),
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
)
val all: List<InvokeCommandSpec> =
@@ -189,9 +187,6 @@ object InvokeCommandRegistry {
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
),
InvokeCommandSpec(
name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild,

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -28,7 +27,6 @@ class InvokeDispatcher(
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
private val callLogHandler: CallLogHandler,
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
@@ -163,9 +161,6 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
// Debug commands
"debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs()

View File

@@ -13,7 +13,6 @@ enum class OpenClawCapability(val rawValue: String) {
Contacts("contacts"),
Calendar("calendar"),
Motion("motion"),
CallLog("callLog"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@@ -138,12 +137,3 @@ enum class OpenClawMotionCommand(val rawValue: String) {
const val NamespacePrefix: String = "motion."
}
}
enum class OpenClawCallLogCommand(val rawValue: String) {
Search("callLog.search"),
;
companion object {
const val NamespacePrefix: String = "callLog."
}
}

View File

@@ -92,28 +92,20 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
title = { Text("Trust this gateway?") },
text = {
Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout,
color = mobileText,
)
},
confirmButton = {
TextButton(
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
) {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
Text("Trust and continue")
}
},
dismissButton = {
TextButton(
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
) {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
Text("Cancel")
}
},

View File

@@ -121,7 +121,6 @@ private enum class PermissionToggle {
Calendar,
Motion,
Sms,
CallLog,
}
private enum class SpecialAccessToggle {
@@ -289,10 +288,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
}
var enableCallLog by
rememberSaveable {
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
}
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
@@ -309,7 +304,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
PermissionToggle.CallLog -> enableCallLog = enabled
}
}
@@ -337,7 +331,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@@ -359,7 +352,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableCalendar,
enableMotion,
enableSms,
enableCallLog,
smsAvailable,
motionAvailable,
) {
@@ -375,7 +367,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS"
if (enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
}
@@ -464,28 +455,19 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
containerColor = onboardingSurface,
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
title = { Text("Trust this gateway?") },
text = {
Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = onboardingCalloutStyle,
color = onboardingText,
)
},
confirmButton = {
TextButton(
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
) {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
Text("Trust and continue")
}
},
dismissButton = {
TextButton(
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
) {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
Text("Cancel")
}
},
@@ -604,7 +586,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms,
smsAvailable = smsAvailable,
enableCallLog = enableCallLog,
context = context,
onDiscoveryChange = { checked ->
requestPermissionToggle(
@@ -702,13 +683,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
}
},
onCallLogChange = { checked ->
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
},
)
OnboardingStep.FinalCheck ->
FinalStep(
@@ -1299,7 +1273,6 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean,
enableSms: Boolean,
smsAvailable: Boolean,
enableCallLog: Boolean,
context: Context,
onDiscoveryChange: (Boolean) -> Unit,
onLocationChange: (Boolean) -> Unit,
@@ -1312,7 +1285,6 @@ private fun PermissionsStep(
onCalendarChange: (Boolean) -> Unit,
onMotionChange: (Boolean) -> Unit,
onSmsChange: (Boolean) -> Unit,
onCallLogChange: (Boolean) -> Unit,
) {
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
val locationGranted =
@@ -1443,15 +1415,6 @@ private fun PermissionsStep(
onCheckedChange = onSmsChange,
)
}
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}

View File

@@ -218,18 +218,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
calendarPermissionGranted = readOk && writeOk
}
var callLogPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED,
)
}
val callLogPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
callLogPermissionGranted = granted
}
var motionPermissionGranted by
remember {
mutableStateOf(
@@ -278,9 +266,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
callLogPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED
motionPermissionGranted =
!motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
@@ -616,31 +601,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
if (motionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(
@@ -822,7 +782,7 @@ private fun openNotificationListenerSettings(context: Context) {
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
PackageManager.PERMISSION_GRANTED
}
private fun isNotificationListenerEnabled(context: Context): Boolean {
@@ -832,5 +792,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}

View File

@@ -128,15 +128,7 @@ fun ChatComposer(
}
}
DropdownMenu(
expanded = showThinkingMenu,
onDismissRequest = { showThinkingMenu = false },
shape = RoundedCornerShape(16.dp),
containerColor = mobileCardSurface,
tonalElevation = 0.dp,
shadowElevation = 8.dp,
border = BorderStroke(1.dp, mobileBorder),
) {
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }

View File

@@ -1,193 +0,0 @@
package ai.openclaw.app.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CallLogHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleCallLogSearch_requiresPermission() {
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false))
val result = handler.handleCallLogSearch(null)
assertFalse(result.ok)
assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleCallLogSearch_rejectsInvalidJson() {
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true))
val result = handler.handleCallLogSearch("invalid json")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleCallLogSearch_returnsCallLogs() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
assertEquals(1709280000000L, callLogs.first().jsonObject.getValue("date").jsonPrimitive.content.toLong())
assertEquals(60L, callLogs.first().jsonObject.getValue("duration").jsonPrimitive.content.toLong())
assertEquals(1, callLogs.first().jsonObject.getValue("type").jsonPrimitive.content.toInt())
}
@Test
fun handleCallLogSearch_withFilters() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 120L,
type = 2,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch(
"""{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}"""
)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withPagination() {
val callLogs =
listOf(
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
),
CallLogRecord(
number = "+654321",
cachedName = "lixuankai2",
date = 1709280001000L,
duration = 120L,
type = 2,
),
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = callLogs),
)
val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogsResult = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogsResult.size)
assertEquals("lixuankai2", callLogsResult.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withDefaultParams() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch(null)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withNullFields() {
val callLog =
CallLogRecord(
number = null,
cachedName = null,
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
// Verify null values are properly serialized
val callLogObj = callLogs.first().jsonObject
assertTrue(callLogObj.containsKey("number"))
assertTrue(callLogObj.containsKey("cachedName"))
}
}
private class FakeCallLogDataSource(
private val canRead: Boolean,
private val searchResults: List<CallLogRecord> = emptyList(),
) : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean = canRead
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val startIndex = request.offset.coerceAtLeast(0)
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
return if (startIndex < searchResults.size) {
searchResults.subList(startIndex, endIndex)
} else {
emptyList()
}
}
}

View File

@@ -93,7 +93,6 @@ class DeviceHandlerTest {
"photos",
"contacts",
"calendar",
"callLog",
"motion",
)
for (key in expected) {

View File

@@ -2,7 +2,6 @@ package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
@@ -26,7 +25,6 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
OpenClawCapability.CallLog.rawValue,
)
private val optionalCapabilities =
@@ -52,7 +50,6 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
OpenClawCallLogCommand.Search.rawValue,
)
private val optionalCommands =

View File

@@ -34,7 +34,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue)
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
}
@Test
@@ -85,9 +84,4 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
}

View File

@@ -18,10 +18,13 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard Self.allMessageNames.contains(message.name) else { return }
// Only accept actions from the in-app canvas scheme. Local-network HTTP
// pages are regular web content and must not get direct agent dispatch.
// Only accept actions from local Canvas content (not arbitrary web pages).
guard let webView = message.webView, let url = webView.url else { return }
guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else {
if let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) {
// ok
} else if Self.isLocalNetworkCanvasURL(url) {
// ok
} else {
return
}
@@ -104,5 +107,10 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
}
}
}
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
}
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
}

View File

@@ -50,24 +50,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
//
// Keep the bridge on the trusted in-app canvas scheme only, and do not
// expose unattended deep-link credentials to page JavaScript.
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
// (includes the app-generated key so it won't prompt).
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let allowedSchemesJSON = (
try? String(
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
encoding: .utf8)
) ?? "[]"
let bridgeScript = """
(() => {
try {
const allowedSchemes = \(allowedSchemesJSON);
const allowedSchemes = \(String(describing: CanvasScheme.allSchemes));
const protocol = location.protocol.replace(':', '');
if (!allowedSchemes.includes(protocol)) return;
if (globalThis.__openclawA2UIBridgeInstalled) return;
globalThis.__openclawA2UIBridgeInstalled = true;
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
@@ -107,8 +104,24 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
return;
}
// Without the native handler, fail closed instead of exposing an
// unattended deep-link credential to page JavaScript.
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
const message =
'CANVAS_A2UI action=' + userAction.name +
' session=' + sessionKey +
' surface=' + userAction.surfaceId +
' component=' + (userAction.sourceComponentId || '-') +
' host=' + machineName.replace(/\\s+/g, '_') +
' instance=' + instanceId +
ctx +
' default=update_canvas';
const params = new URLSearchParams();
params.set('message', message);
params.set('sessionKey', sessionKey);
params.set('thinking', 'low');
params.set('deliver', 'false');
params.set('channel', 'last');
params.set('key', deepLinkKey);
location.href = 'openclaw://agent?' + params.toString();
} catch {}
}, true);
} catch {}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4889}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4733}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -101,7 +101,6 @@
{"recordType":"path","path":"agents.defaults.compaction.recentTurnsPreserve","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Preserve Recent Turns","help":"Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.reserveTokens","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Tokens","help":"Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.reserveTokensFloor","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Token Floor","help":"Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Compaction Timeout (Seconds)","help":"Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.contextPruning","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -144,7 +143,7 @@
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false}
@@ -348,7 +347,7 @@
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -913,8 +912,6 @@
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1168,8 +1165,6 @@
{"recordType":"path","path":"channels.discord.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1285,182 +1280,61 @@
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.resolveSenderNames","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.typingIndicator","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":true,"enumValues":["websocket","webhook"],"defaultValue":"websocket","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","pairing","allowlist"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":true,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.agentDirTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.maxAgents","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.workspaceTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.typingIndicator","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -1468,7 +1342,6 @@
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1504,8 +1377,6 @@
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1530,7 +1401,6 @@
{"recordType":"path","path":"channels.googlechat.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1567,8 +1437,6 @@
{"recordType":"path","path":"channels.googlechat.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1636,8 +1504,6 @@
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1699,8 +1565,6 @@
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.imessage.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.imessage.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.imessage.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2105,8 +1969,6 @@
{"recordType":"path","path":"channels.msteams.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.groupAllowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2352,8 +2214,6 @@
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2422,8 +2282,6 @@
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.signal.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.signal.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.signal.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2528,8 +2386,6 @@
{"recordType":"path","path":"channels.slack.accounts.*.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2653,8 +2509,6 @@
{"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2834,8 +2688,6 @@
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3010,8 +2862,6 @@
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3182,8 +3032,6 @@
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3247,8 +3095,6 @@
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.whatsapp.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.whatsapp.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.whatsapp.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3484,8 +3330,6 @@
{"recordType":"path","path":"gateway.auth.trustedProxy.userHeader","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"gateway.bind","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Bind Mode","help":"Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.","hasChildren":false}
{"recordType":"path","path":"gateway.channelHealthCheckMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Gateway Channel Health Check Interval (min)","help":"Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.","hasChildren":false}
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3740,7 +3584,7 @@
{"recordType":"path","path":"messages.ackReactionScope","kind":"core","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","off","none"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Ack Reaction Scope","help":"When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.","hasChildren":false}
{"recordType":"path","path":"messages.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Chat Rules","help":"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.","hasChildren":true}
{"recordType":"path","path":"messages.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Group History Limit","help":"Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.","hasChildren":false}
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.","hasChildren":true}
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.","hasChildren":true}
{"recordType":"path","path":"messages.groupChat.mentionPatterns.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"messages.inbound","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce","help":"Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.","hasChildren":true}
{"recordType":"path","path":"messages.inbound.byChannel","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce by Channel (ms)","help":"Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.","hasChildren":true}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -532,75 +532,6 @@ Feishu supports streaming replies via interactive cards. When enabled, the bot u
Set `streaming: false` to wait for the full reply before sending.
### ACP sessions
Feishu supports ACP for:
- DMs
- group topic conversations
Feishu ACP is text-command driven. There are no native slash-command menus, so use `/acp ...` messages directly in the conversation.
#### Persistent ACP bindings
Use top-level typed ACP bindings to pin a Feishu DM or topic conversation to a persistent ACP session.
```json5
{
agents: {
list: [
{
id: "codex",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
},
],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "feishu",
accountId: "default",
peer: { kind: "direct", id: "ou_1234567890" },
},
},
{
type: "acp",
agentId: "codex",
match: {
channel: "feishu",
accountId: "default",
peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root" },
},
acp: { label: "codex-feishu-topic" },
},
],
}
```
#### Thread-bound ACP spawn from chat
In a Feishu DM or topic conversation, you can spawn and bind an ACP session in place:
```text
/acp spawn codex --thread here
```
Notes:
- `--thread here` works for DMs and Feishu topics.
- Follow-up messages in the bound DM/topic route directly to that ACP session.
- v1 does not target generic non-topic group chats.
### Multi-agent routing
Use `bindings` to route Feishu DMs or groups to different agents.

View File

@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: **pending-only** group messages (default 50) that _did not_ trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
@@ -50,7 +50,7 @@ Add a `groupChat` block to `~/.openclaw/openclaw.json` so display-name pings wor
Notes:
- The regexes are case-insensitive and use the same safe-regex guardrails as other config regex surfaces; invalid patterns and unsafe nested repetition are ignored.
- The regexes are case-insensitive; they cover a display-name ping like `@openclaw` and the raw number with or without `+`/spaces.
- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a useful safety net.
### Activation command (owner-only)

View File

@@ -243,7 +243,7 @@ Replying to a bot message counts as an implicit mention (when the channel suppor
Notes:
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
- `mentionPatterns` are case-insensitive regexes.
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).

View File

@@ -655,12 +655,12 @@ See the full channel index: [Channels](/channels).
### Group chat mention gating
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
Group messages default to **require mention** (metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
- **Text patterns**: Safe regex patterns in `agents.list[].groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored.
- **Text patterns**: Regex patterns in `agents.list[].groupChat.mentionPatterns`. Always checked.
- Mention gating is enforced only when detection is possible (native mentions or at least one pattern).
```json5
@@ -1005,7 +1005,6 @@ Periodic heartbeat runs.
defaults: {
compaction: {
mode: "safeguard", // default | safeguard
timeoutSeconds: 900,
reserveTokensFloor: 24000,
identifierPolicy: "strict", // strict | off | custom
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
@@ -1024,7 +1023,6 @@ Periodic heartbeat runs.
```
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
@@ -2372,7 +2370,6 @@ See [Plugins](/tools/plugin).
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model).
- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation.
- In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks.
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
- Remote profiles are attach-only (start/stop/reset disabled).
@@ -2490,11 +2487,6 @@ See [Plugins](/tools/plugin).
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
- `gateway.channelHealthCheckMinutes`: channel health-monitor interval in minutes. Set `0` to disable health-monitor restarts globally. Default: `5`.
- `gateway.channelStaleEventThresholdMinutes`: stale-socket threshold in minutes. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. Default: `30`.
- `gateway.channelMaxRestartsPerHour`: maximum health-monitor restarts per channel/account in a rolling hour. Default: `10`.
- `channels.<provider>.healthMonitor.enabled`: per-channel opt-out for health-monitor restarts while keeping the global monitor enabled.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: per-account override for multi-account channels. When set, it takes precedence over the channel-level override.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.

View File

@@ -170,41 +170,11 @@ When validation fails:
```
- **Metadata mentions**: native @-mentions (WhatsApp tap-to-mention, Telegram @bot, etc.)
- **Text patterns**: safe regex patterns in `mentionPatterns`
- **Text patterns**: regex patterns in `mentionPatterns`
- See [full reference](/gateway/configuration-reference#group-chat-mention-gating) for per-channel overrides and self-chat mode.
</Accordion>
<Accordion title="Tune gateway channel health monitoring">
Control how aggressively the gateway restarts channels that look stale:
```json5
{
gateway: {
channelHealthCheckMinutes: 5,
channelStaleEventThresholdMinutes: 30,
channelMaxRestartsPerHour: 10,
},
channels: {
telegram: {
healthMonitor: { enabled: false },
accounts: {
alerts: {
healthMonitor: { enabled: true },
},
},
},
},
}
```
- Set `gateway.channelHealthCheckMinutes: 0` to disable health-monitor restarts globally.
- `channelStaleEventThresholdMinutes` should be greater than or equal to the check interval.
- Use `channels.<provider>.healthMonitor.enabled` or `channels.<provider>.accounts.<id>.healthMonitor.enabled` to disable auto-restarts for one channel or account without disabling the global monitor.
- See [Health Checks](/gateway/health) for operational debugging and the [full reference](/gateway/configuration-reference#gateway) for all fields.
</Accordion>
<Accordion title="Configure sessions and resets">
Sessions control conversation continuity and isolation:

View File

@@ -24,15 +24,6 @@ Short guide to verify channel connectivity without guessing.
- Session store: `ls -l ~/.openclaw/agents/<agentId>/sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
## Health monitor config
- `gateway.channelHealthCheckMinutes`: how often the gateway checks channel health. Default: `5`. Set `0` to disable health-monitor restarts globally.
- `gateway.channelStaleEventThresholdMinutes`: how long a connected channel can stay idle before the health monitor treats it as stale and restarts it. Default: `30`. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`.
- `gateway.channelMaxRestartsPerHour`: rolling one-hour cap for health-monitor restarts per channel/account. Default: `10`.
- `channels.<provider>.healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
## When something fails
- `logged out` or status 409515 → relink with `openclaw channels logout` then `openclaw channels login`.

View File

@@ -285,7 +285,6 @@ Available families:
- `photos.latest`
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer`
Example invokes:

View File

@@ -163,5 +163,4 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `photos.latest`
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer`

View File

@@ -114,7 +114,6 @@ Notes:
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation.
- In strict SSRF mode, remote CDP endpoint discovery/probes (`cdpUrl`, including `/json/version` lookups) are checked too.
- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing.
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”

View File

@@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
const payload: SetupPayload = {
url: urlResult.url,
bootstrapToken: (await issueDeviceBootstrapToken()).token,
bootstrapToken: (
await issueDeviceBootstrapToken({
role: "node",
scopes: [],
})
).token,
};
if (action === "qr") {

View File

@@ -1,68 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it, vi } from "vitest";
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
vi.mock("./src/docx.js", () => ({
registerFeishuDocTools: registerFeishuDocToolsMock,
}));
vi.mock("./src/chat.js", () => ({
registerFeishuChatTools: registerFeishuChatToolsMock,
}));
vi.mock("./src/wiki.js", () => ({
registerFeishuWikiTools: registerFeishuWikiToolsMock,
}));
vi.mock("./src/drive.js", () => ({
registerFeishuDriveTools: registerFeishuDriveToolsMock,
}));
vi.mock("./src/perm.js", () => ({
registerFeishuPermTools: registerFeishuPermToolsMock,
}));
vi.mock("./src/bitable.js", () => ({
registerFeishuBitableTools: registerFeishuBitableToolsMock,
}));
vi.mock("./src/runtime.js", () => ({
setFeishuRuntime: setFeishuRuntimeMock,
}));
vi.mock("./src/subagent-hooks.js", () => ({
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
}));
describe("feishu plugin register", () => {
it("registers the Feishu channel, tools, and subagent hooks", async () => {
const { default: plugin } = await import("./index.js");
const registerChannel = vi.fn();
const api = {
runtime: { log: vi.fn() },
registerChannel,
on: vi.fn(),
config: {},
} as unknown as OpenClawPluginApi;
plugin.register(api);
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
expect(registerChannel).toHaveBeenCalledTimes(1);
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
});
});

View File

@@ -7,7 +7,6 @@ import { registerFeishuDocTools } from "./src/docx.js";
import { registerFeishuDriveTools } from "./src/drive.js";
import { registerFeishuPermTools } from "./src/perm.js";
import { setFeishuRuntime } from "./src/runtime.js";
import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js";
import { registerFeishuWikiTools } from "./src/wiki.js";
export { monitorFeishuProvider } from "./src/monitor.js";
@@ -54,7 +53,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setFeishuRuntime(api.runtime);
api.registerChannel({ plugin: feishuPlugin });
registerFeishuSubagentHooks(api);
registerFeishuDocTools(api);
registerFeishuChatTools(api);
registerFeishuWikiTools(api);

View File

@@ -21,10 +21,6 @@ const {
mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
mockResolveConfiguredAcpRoute,
mockEnsureConfiguredAcpRouteReady,
mockResolveBoundConversation,
mockTouchBinding,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
@@ -50,13 +46,6 @@ const {
})),
mockReadSessionUpdatedAt: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
configuredBinding: null,
route,
})),
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
mockResolveBoundConversation: vi.fn(() => null),
mockTouchBinding: vi.fn(),
}));
vi.mock("./reply-dispatcher.js", () => ({
@@ -77,18 +66,6 @@ vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
}));
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
resolveByConversation: mockResolveBoundConversation,
touch: mockTouchBinding,
}),
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
@@ -133,261 +110,6 @@ describe("buildFeishuAgentBody", () => {
});
});
describe("handleFeishuMessage ACP routing", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
({ route }) =>
({
configuredBinding: null,
route,
}) as any,
);
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
mockResolveBoundConversation.mockReset().mockReturnValue(null);
mockTouchBinding.mockReset();
mockResolveAgentRoute.mockReset().mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:feishu:direct:ou_sender_1",
mainSessionKey: "agent:main:main",
matchedBy: "default",
});
mockSendMessageFeishu
.mockReset()
.mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
dispatcher: {
sendToolResult: vi.fn(),
sendBlockReply: vi.fn(),
sendFinalReply: vi.fn(),
waitForIdle: vi.fn(),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
} as any,
replyOptions: {},
markDispatchIdle: vi.fn(),
});
setFeishuRuntime(
createPluginRuntimeMock({
channel: {
routing: {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
session: {
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(
() => ({}),
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: ((ctx: unknown) =>
ctx) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
dispatchReplyFromConfig: vi.fn().mockResolvedValue({
queuedFinal: false,
counts: { final: 1 },
}),
withReplyDispatcher: vi.fn(
async ({
run,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) =>
await run(),
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => false),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
},
pairing: {
readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
upsertPairingRequest: vi.fn(),
buildPairingReply: vi.fn(),
},
},
}),
);
});
it("ensures configured ACP routes for Feishu DMs", async () => {
mockResolveConfiguredAcpRoute.mockReturnValue({
configuredBinding: {
spec: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:feishu:default:ou_sender_1",
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
},
status: "active",
boundAt: 0,
metadata: { source: "config" },
},
},
route: {
agentId: "codex",
channel: "feishu",
accountId: "default",
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
mainSessionKey: "agent:codex:main",
matchedBy: "binding.channel",
},
} as any);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-1",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
});
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
mockResolveConfiguredAcpRoute.mockReturnValue({
configuredBinding: {
spec: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:feishu:default:ou_sender_1",
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "ou_sender_1",
},
status: "active",
boundAt: 0,
metadata: { source: "config" },
},
},
route: {
agentId: "codex",
channel: "feishu",
accountId: "default",
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
mainSessionKey: "agent:codex:main",
matchedBy: "binding.channel",
},
} as any);
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
ok: false,
error: "runtime unavailable",
} as any);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-2",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_dm",
text: expect.stringContaining("runtime unavailable"),
}),
);
});
it("routes Feishu topic messages through active bound conversations", async () => {
mockResolveBoundConversation.mockReturnValue({
bindingId: "default:oc_group_chat:topic:om_topic_root",
targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
status: "active",
boundAt: 0,
} as any);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: {
feishu: {
enabled: true,
allowFrom: ["ou_sender_1"],
groups: {
oc_group_chat: {
allow: true,
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-3",
chat_id: "oc_group_chat",
chat_type: "group",
message_type: "text",
root_id: "om_topic_root",
content: JSON.stringify({ text: "hello topic" }),
},
},
});
expect(mockResolveBoundConversation).toHaveBeenCalledWith(
expect.objectContaining({
channel: "feishu",
conversationId: "oc_group_chat:topic:om_topic_root",
}),
);
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
});
});
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
@@ -431,16 +153,6 @@ describe("handleFeishuMessage command authorization", () => {
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
mockReadSessionUpdatedAt.mockReturnValue(undefined);
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
({ route }) =>
({
configuredBinding: null,
route,
}) as any,
);
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
mockResolveBoundConversation.mockReset().mockReturnValue(null);
mockTouchBinding.mockReset();
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",

View File

@@ -14,16 +14,8 @@ import {
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/feishu";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../../../src/acp/persistent-bindings.route.js";
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
import { deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { buildFeishuConversationId } from "./conversation-id.js";
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
@@ -281,34 +273,15 @@ function resolveFeishuGroupSession(params: {
let peerId = chatId;
switch (groupSessionScope) {
case "group_sender":
peerId = buildFeishuConversationId({
chatId,
scope: "group_sender",
senderOpenId,
});
peerId = `${chatId}:sender:${senderOpenId}`;
break;
case "group_topic":
peerId = topicScope
? buildFeishuConversationId({
chatId,
scope: "group_topic",
topicId: topicScope,
})
: chatId;
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
break;
case "group_topic_sender":
peerId = topicScope
? buildFeishuConversationId({
chatId,
scope: "group_topic_sender",
topicId: topicScope,
senderOpenId,
})
: buildFeishuConversationId({
chatId,
scope: "group_sender",
senderOpenId,
});
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
: `${chatId}:sender:${senderOpenId}`;
break;
case "group":
default:
@@ -1195,10 +1168,6 @@ export async function handleFeishuMessage(params: {
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
const feishuAcpConversationSupported =
!isGroup ||
groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender";
if (isGroup && groupSession) {
log(
@@ -1247,76 +1216,6 @@ export async function handleFeishuMessage(params: {
}
}
const currentConversationId = peerId;
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
let configuredBinding = null;
if (feishuAcpConversationSupported) {
const configuredRoute = resolveConfiguredAcpRoute({
cfg: effectiveCfg,
route,
channel: "feishu",
accountId: account.accountId,
conversationId: currentConversationId,
parentConversationId,
});
configuredBinding = configuredRoute.configuredBinding;
route = configuredRoute.route;
// Bound Feishu conversations intentionally require an exact live conversation-id match.
// Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
// configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
const threadBinding = getSessionBindingService().resolveByConversation({
channel: "feishu",
accountId: account.accountId,
conversationId: currentConversationId,
...(parentConversationId ? { parentConversationId } : {}),
});
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
if (threadBinding && boundSessionKey) {
route = {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
configuredBinding = null;
getSessionBindingService().touch(threadBinding.bindingId);
log(
`feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`,
);
}
}
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg: effectiveCfg,
configuredBinding,
});
if (!ensured.ok) {
const replyTargetMessageId =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender")
? (ctx.rootId ?? ctx.messageId)
: ctx.messageId;
await sendMessageFeishu({
cfg: effectiveCfg,
to: `chat:${ctx.chatId}`,
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
replyToMessageId: replyTargetMessageId,
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
accountId: account.accountId,
}).catch((err) => {
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
});
return;
}
}
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`

View File

@@ -1,125 +0,0 @@
export type FeishuGroupSessionScope =
| "group"
| "group_sender"
| "group_topic"
| "group_topic_sender";
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function buildFeishuConversationId(params: {
chatId: string;
scope: FeishuGroupSessionScope;
senderOpenId?: string;
topicId?: string;
}): string {
const chatId = normalizeText(params.chatId) ?? "unknown";
const senderOpenId = normalizeText(params.senderOpenId);
const topicId = normalizeText(params.topicId);
switch (params.scope) {
case "group_sender":
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
case "group_topic":
return topicId ? `${chatId}:topic:${topicId}` : chatId;
case "group_topic_sender":
if (topicId && senderOpenId) {
return `${chatId}:topic:${topicId}:sender:${senderOpenId}`;
}
if (topicId) {
return `${chatId}:topic:${topicId}`;
}
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
case "group":
default:
return chatId;
}
}
export function parseFeishuConversationId(params: {
conversationId: string;
parentConversationId?: string;
}): {
canonicalConversationId: string;
chatId: string;
topicId?: string;
senderOpenId?: string;
scope: FeishuGroupSessionScope;
} | null {
const conversationId = normalizeText(params.conversationId);
const parentConversationId = normalizeText(params.parentConversationId);
if (!conversationId) {
return null;
}
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/);
if (topicSenderMatch) {
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
return {
canonicalConversationId: buildFeishuConversationId({
chatId,
scope: "group_topic_sender",
topicId,
senderOpenId,
}),
chatId,
topicId,
senderOpenId,
scope: "group_topic_sender",
};
}
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/);
if (topicMatch) {
const [, chatId, topicId] = topicMatch;
return {
canonicalConversationId: buildFeishuConversationId({
chatId,
scope: "group_topic",
topicId,
}),
chatId,
topicId,
scope: "group_topic",
};
}
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/);
if (senderMatch) {
const [, chatId, senderOpenId] = senderMatch;
return {
canonicalConversationId: buildFeishuConversationId({
chatId,
scope: "group_sender",
senderOpenId,
}),
chatId,
senderOpenId,
scope: "group_sender",
};
}
if (parentConversationId) {
return {
canonicalConversationId: buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic",
topicId: conversationId,
}),
chatId: parentConversationId,
topicId: conversationId,
scope: "group_topic",
};
}
return {
canonicalConversationId: conversationId,
chatId: conversationId,
scope: "group",
};
}

View File

@@ -24,7 +24,6 @@ import { botNames, botOpenIds } from "./monitor.state.js";
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
import { createFeishuThreadBindingManager } from "./thread-bindings.js";
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
@@ -632,25 +631,19 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
}
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
try {
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();
threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg });
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();
registerEventHandlers(eventDispatcher, {
cfg,
accountId,
runtime,
chatHistories,
fireAndForget: true,
});
registerEventHandlers(eventDispatcher, {
cfg,
accountId,
runtime,
chatHistories,
fireAndForget: true,
});
if (connectionMode === "webhook") {
return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
}
return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
} finally {
threadBindingManager?.stop();
if (connectionMode === "webhook") {
return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
}
return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
}

View File

@@ -17,7 +17,6 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
@@ -38,10 +37,6 @@ vi.mock("./monitor.transport.js", () => ({
monitorWebhook: monitorWebhookMock,
}));
vi.mock("./thread-bindings.js", () => ({
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
}));
const cfg = {} as ClawdbotConfig;
function makeReactionEvent(
@@ -424,94 +419,6 @@ describe("resolveReactionSyntheticEvent", () => {
});
});
describe("monitorSingleAccount lifecycle", () => {
beforeEach(() => {
createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
stop: vi.fn(),
}));
createEventDispatcherMock.mockReset().mockReturnValue({
register: vi.fn(),
});
});
it("stops the Feishu thread binding manager when the monitor exits", async () => {
setFeishuRuntime(
createPluginRuntimeMock({
channel: {
debounce: {
resolveInboundDebounceMs,
createInboundDebouncer,
},
text: {
hasControlCommand,
},
},
}),
);
await monitorSingleAccount({
cfg: buildDebounceConfig(),
account: buildDebounceAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
},
});
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
| { stop: ReturnType<typeof vi.fn> }
| undefined;
expect(manager?.stop).toHaveBeenCalledTimes(1);
});
it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
setFeishuRuntime(
createPluginRuntimeMock({
channel: {
debounce: {
resolveInboundDebounceMs,
createInboundDebouncer,
},
text: {
hasControlCommand,
},
},
}),
);
createEventDispatcherMock.mockReturnValue({
get register() {
throw new Error("register failed");
},
});
await expect(
monitorSingleAccount({
cfg: buildDebounceConfig(),
account: buildDebounceAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
},
}),
).rejects.toThrow("register failed");
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
| { stop: ReturnType<typeof vi.fn> }
| undefined;
expect(manager?.stop).toHaveBeenCalledTimes(1);
});
});
describe("Feishu inbound debounce regressions", () => {
beforeEach(() => {
vi.useFakeTimers();

View File

@@ -1,623 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerFeishuSubagentHooks } from "./subagent-hooks.js";
import {
__testing as threadBindingTesting,
createFeishuThreadBindingManager,
} from "./thread-bindings.js";
const baseConfig = {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: {} },
};
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
const api = {
config,
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
handlers.set(hookName, handler);
},
} as unknown as OpenClawPluginApi;
registerFeishuSubagentHooks(api);
return handlers;
}
function getRequiredHandler(
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
hookName: string,
): (event: unknown, ctx: unknown) => unknown {
const handler = handlers.get(hookName);
if (!handler) {
throw new Error(`expected ${hookName} hook handler`);
}
return handler;
}
describe("feishu subagent hook handlers", () => {
beforeEach(() => {
threadBindingTesting.resetFeishuThreadBindingsForTests();
});
it("registers Feishu subagent hooks", () => {
const handlers = registerHandlersForTest();
expect(handlers.has("subagent_spawning")).toBe(true);
expect(handlers.has("subagent_delivery_target")).toBe(true);
expect(handlers.has("subagent_ended")).toBe(true);
expect(handlers.has("subagent_spawned")).toBe(false);
});
it("binds a Feishu DM conversation on subagent_spawning", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHandler(handlers, "subagent_spawning");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
label: "banana",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
);
expect(result).toEqual({ status: "ok", threadBindingReady: true });
const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target");
expect(
deliveryTargetHandler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
});
});
it("preserves the original Feishu DM delivery target", async () => {
const handlers = registerHandlersForTest();
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "ou_sender_1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:chat-dm-child",
metadata: {
deliveryTo: "chat:oc_dm_chat_1",
boundBy: "system",
},
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:chat-dm-child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_dm_chat_1",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_dm_chat_1",
},
});
});
it("binds a Feishu topic conversation and preserves parent context", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
const result = await spawnHandler(
{
childSessionKey: "agent:main:subagent:topic-child",
agentId: "codex",
label: "topic-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{},
);
expect(result).toEqual({ status: "ok", threadBindingReady: true });
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:topic-child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
});
});
it("uses the requester session binding to preserve sender-scoped topic conversations", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: {
agentId: "codex",
label: "parent",
boundBy: "system",
},
});
const reboundResult = await spawnHandler(
{
childSessionKey: "agent:main:subagent:sender-child",
agentId: "codex",
label: "sender-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{
requesterSessionKey: "agent:main:parent",
},
);
expect(reboundResult).toEqual({ status: "ok", threadBindingReady: true });
expect(manager.listBySessionKey("agent:main:subagent:sender-child")).toMatchObject([
{
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
},
]);
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:sender-child",
requesterSessionKey: "agent:main:parent",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
});
});
it("prefers requester-matching bindings when multiple child bindings exist", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
await spawnHandler(
{
childSessionKey: "agent:main:subagent:shared",
agentId: "codex",
label: "shared",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
);
await spawnHandler(
{
childSessionKey: "agent:main:subagent:shared",
agentId: "codex",
label: "shared",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_2",
},
threadRequested: true,
},
{},
);
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:shared",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_2",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_2",
},
});
});
it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_2",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:ambiguous-child",
agentId: "codex",
label: "ambiguous-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{
requesterSessionKey: "agent:main:parent",
},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("direct messages or topic conversations"),
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:ambiguous-child",
requesterSessionKey: "agent:main:parent",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:mixed-topic-child",
agentId: "codex",
label: "mixed-topic-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{
requesterSessionKey: "agent:main:parent",
},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("direct messages or topic conversations"),
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:mixed-topic-child",
requesterSessionKey: "agent:main:parent",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
it("no-ops for non-Feishu channels and non-threaded spawns", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "run",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
),
).resolves.toBeUndefined();
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "run",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: false,
},
{},
),
).resolves.toBeUndefined();
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
expect(
endedHandler(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "done",
accountId: "work",
},
{},
),
).toBeUndefined();
});
it("returns an error for unsupported non-topic Feishu group conversations", async () => {
const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
await expect(
handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
},
threadRequested: true,
},
{},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("direct messages or topic conversations"),
});
});
it("unbinds Feishu bindings on subagent_ended", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
await spawnHandler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
);
endedHandler(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "done",
accountId: "work",
},
{},
);
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:no-manager",
agentId: "codex",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("monitor is not active"),
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:no-manager",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
});

View File

@@ -1,341 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js";
import { normalizeFeishuTarget } from "./targets.js";
import { getFeishuThreadBindingManager } from "./thread-bindings.js";
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
return "error";
}
function stripProviderPrefix(raw: string): string {
return raw.replace(/^(feishu|lark):/i, "").trim();
}
function resolveFeishuRequesterConversation(params: {
accountId?: string;
to?: string;
threadId?: string | number;
requesterSessionKey?: string;
}): {
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const manager = getFeishuThreadBindingManager(params.accountId);
if (!manager) {
return null;
}
const rawTo = params.to?.trim();
const withoutProviderPrefix = rawTo ? stripProviderPrefix(rawTo) : "";
const normalizedTarget = rawTo ? normalizeFeishuTarget(rawTo) : null;
const threadId =
params.threadId != null && params.threadId !== "" ? String(params.threadId).trim() : "";
const isChatTarget = /^(chat|group|channel):/i.test(withoutProviderPrefix);
const parsedRequesterTopic =
normalizedTarget && threadId && isChatTarget
? parseFeishuConversationId({
conversationId: buildFeishuConversationId({
chatId: normalizedTarget,
scope: "group_topic",
topicId: threadId,
}),
parentConversationId: normalizedTarget,
})
: null;
const requesterSessionKey = params.requesterSessionKey?.trim();
if (requesterSessionKey) {
const existingBindings = manager.listBySessionKey(requesterSessionKey);
if (existingBindings.length === 1) {
const existing = existingBindings[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
if (existingBindings.length > 1) {
if (rawTo && normalizedTarget && !threadId && !isChatTarget) {
const directMatches = existingBindings.filter(
(entry) =>
entry.accountId === manager.accountId &&
entry.conversationId === normalizedTarget &&
!entry.parentConversationId,
);
if (directMatches.length === 1) {
const existing = directMatches[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
return null;
}
if (parsedRequesterTopic) {
const matchingTopicBindings = existingBindings.filter((entry) => {
const parsed = parseFeishuConversationId({
conversationId: entry.conversationId,
parentConversationId: entry.parentConversationId,
});
return (
parsed?.chatId === parsedRequesterTopic.chatId &&
parsed?.topicId === parsedRequesterTopic.topicId
);
});
if (matchingTopicBindings.length === 1) {
const existing = matchingTopicBindings[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
const senderScopedTopicBindings = matchingTopicBindings.filter((entry) => {
const parsed = parseFeishuConversationId({
conversationId: entry.conversationId,
parentConversationId: entry.parentConversationId,
});
return parsed?.scope === "group_topic_sender";
});
if (
senderScopedTopicBindings.length === 1 &&
matchingTopicBindings.length === senderScopedTopicBindings.length
) {
const existing = senderScopedTopicBindings[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
return null;
}
}
}
if (!rawTo) {
return null;
}
if (!normalizedTarget) {
return null;
}
if (threadId) {
if (!isChatTarget) {
return null;
}
return {
accountId: manager.accountId,
conversationId: buildFeishuConversationId({
chatId: normalizedTarget,
scope: "group_topic",
topicId: threadId,
}),
parentConversationId: normalizedTarget,
};
}
if (isChatTarget) {
return null;
}
return {
accountId: manager.accountId,
conversationId: normalizedTarget,
};
}
function resolveFeishuDeliveryOrigin(params: {
conversationId: string;
parentConversationId?: string;
accountId: string;
deliveryTo?: string;
deliveryThreadId?: string;
}): {
channel: "feishu";
accountId: string;
to: string;
threadId?: string;
} {
const deliveryTo = params.deliveryTo?.trim();
const deliveryThreadId = params.deliveryThreadId?.trim();
if (deliveryTo) {
return {
channel: "feishu",
accountId: params.accountId,
to: deliveryTo,
...(deliveryThreadId ? { threadId: deliveryThreadId } : {}),
};
}
const parsed = parseFeishuConversationId({
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (parsed?.topicId) {
return {
channel: "feishu",
accountId: params.accountId,
to: `chat:${params.parentConversationId?.trim() || parsed.chatId}`,
threadId: parsed.topicId,
};
}
return {
channel: "feishu",
accountId: params.accountId,
to: `user:${params.conversationId}`,
};
}
function resolveMatchingChildBinding(params: {
accountId?: string;
childSessionKey: string;
requesterSessionKey?: string;
requesterOrigin?: {
to?: string;
threadId?: string | number;
};
}) {
const manager = getFeishuThreadBindingManager(params.accountId);
if (!manager) {
return null;
}
const childBindings = manager.listBySessionKey(params.childSessionKey.trim());
if (childBindings.length === 0) {
return null;
}
const requesterConversation = resolveFeishuRequesterConversation({
accountId: manager.accountId,
to: params.requesterOrigin?.to,
threadId: params.requesterOrigin?.threadId,
requesterSessionKey: params.requesterSessionKey,
});
if (requesterConversation) {
const matched = childBindings.find(
(entry) =>
entry.accountId === requesterConversation.accountId &&
entry.conversationId === requesterConversation.conversationId &&
(entry.parentConversationId?.trim() || undefined) ===
(requesterConversation.parentConversationId?.trim() || undefined),
);
if (matched) {
return matched;
}
}
return childBindings.length === 1 ? childBindings[0] : null;
}
export function registerFeishuSubagentHooks(api: OpenClawPluginApi) {
api.on("subagent_spawning", async (event, ctx) => {
if (!event.threadRequested) {
return;
}
const requesterChannel = event.requester?.channel?.trim().toLowerCase();
if (requesterChannel !== "feishu") {
return;
}
const manager = getFeishuThreadBindingManager(event.requester?.accountId);
if (!manager) {
return {
status: "error" as const,
error:
"Feishu current-conversation binding is unavailable because the Feishu account monitor is not active.",
};
}
const conversation = resolveFeishuRequesterConversation({
accountId: event.requester?.accountId,
to: event.requester?.to,
threadId: event.requester?.threadId,
requesterSessionKey: ctx.requesterSessionKey,
});
if (!conversation) {
return {
status: "error" as const,
error:
"Feishu current-conversation binding is only available in direct messages or topic conversations.",
};
}
try {
const binding = manager.bindConversation({
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
targetKind: "subagent",
targetSessionKey: event.childSessionKey,
metadata: {
agentId: event.agentId,
label: event.label,
boundBy: "system",
deliveryTo: event.requester?.to,
deliveryThreadId:
event.requester?.threadId != null && event.requester.threadId !== ""
? String(event.requester.threadId)
: undefined,
},
});
if (!binding) {
return {
status: "error" as const,
error:
"Unable to bind this Feishu conversation to the spawned subagent session. Session mode is unavailable for this target.",
};
}
return {
status: "ok" as const,
threadBindingReady: true,
};
} catch (err) {
return {
status: "error" as const,
error: `Feishu conversation bind failed: ${summarizeError(err)}`,
};
}
});
api.on("subagent_delivery_target", (event) => {
if (!event.expectsCompletionMessage) {
return;
}
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
if (requesterChannel !== "feishu") {
return;
}
const binding = resolveMatchingChildBinding({
accountId: event.requesterOrigin?.accountId,
childSessionKey: event.childSessionKey,
requesterSessionKey: event.requesterSessionKey,
requesterOrigin: {
to: event.requesterOrigin?.to,
threadId: event.requesterOrigin?.threadId,
},
});
if (!binding) {
return;
}
return {
origin: resolveFeishuDeliveryOrigin({
conversationId: binding.conversationId,
parentConversationId: binding.parentConversationId,
accountId: binding.accountId,
deliveryTo: binding.deliveryTo,
deliveryThreadId: binding.deliveryThreadId,
}),
};
});
api.on("subagent_ended", (event) => {
const manager = getFeishuThreadBindingManager(event.accountId);
manager?.unbindBySessionKey(event.targetSessionKey);
});
}

View File

@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
import { __testing, createFeishuThreadBindingManager } from "./thread-bindings.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
describe("Feishu thread bindings", () => {
beforeEach(() => {
__testing.resetFeishuThreadBindingsForTests();
});
it("registers current-placement adapter capabilities for Feishu", () => {
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
expect(
getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
}),
).toEqual({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
});
});
it("binds and resolves a Feishu topic conversation", async () => {
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
const binding = await getSessionBindingService().bind({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expect(binding.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
expect(
getSessionBindingService().resolveByConversation({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
}),
)?.toMatchObject({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
metadata: expect.objectContaining({
agentId: "codex",
label: "codex-main",
}),
});
});
it("clears account-scoped bindings when the manager stops", async () => {
const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
await getSessionBindingService().bind({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "codex",
},
});
manager.stop();
expect(
getSessionBindingService().resolveByConversation({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
}),
).toBeNull();
});
});

View File

@@ -1,316 +0,0 @@
import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js";
import {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "../../../src/channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingRecord,
} from "../../../src/infra/outbound/session-binding-service.js";
import {
normalizeAccountId,
resolveAgentIdFromSessionKey,
} from "../../../src/routing/session-key.js";
import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js";
type FeishuBindingTargetKind = "subagent" | "acp";
type FeishuThreadBindingRecord = {
accountId: string;
conversationId: string;
parentConversationId?: string;
deliveryTo?: string;
deliveryThreadId?: string;
targetKind: FeishuBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
boundAt: number;
lastActivityAt: number;
};
type FeishuThreadBindingManager = {
accountId: string;
getByConversationId: (conversationId: string) => FeishuThreadBindingRecord | undefined;
listBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[];
bindConversation: (params: {
conversationId: string;
parentConversationId?: string;
targetKind: BindingTargetKind;
targetSessionKey: string;
metadata?: Record<string, unknown>;
}) => FeishuThreadBindingRecord | null;
touchConversation: (conversationId: string, at?: number) => FeishuThreadBindingRecord | null;
unbindConversation: (conversationId: string) => FeishuThreadBindingRecord | null;
unbindBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[];
stop: () => void;
};
type FeishuThreadBindingsState = {
managersByAccountId: Map<string, FeishuThreadBindingManager>;
bindingsByAccountConversation: Map<string, FeishuThreadBindingRecord>;
};
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
const state = resolveGlobalSingleton<FeishuThreadBindingsState>(
FEISHU_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map(),
bindingsByAccountConversation: new Map(),
}),
);
const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId;
const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation;
function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
return `${params.accountId}:${params.conversationId}`;
}
function toSessionBindingTargetKind(raw: FeishuBindingTargetKind): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
function toFeishuTargetKind(raw: BindingTargetKind): FeishuBindingTargetKind {
return raw === "subagent" ? "subagent" : "acp";
}
function toSessionBindingRecord(
record: FeishuThreadBindingRecord,
defaults: { idleTimeoutMs: number; maxAgeMs: number },
): SessionBindingRecord {
const idleExpiresAt =
defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined;
const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined;
const expiresAt =
idleExpiresAt != null && maxAgeExpiresAt != null
? Math.min(idleExpiresAt, maxAgeExpiresAt)
: (idleExpiresAt ?? maxAgeExpiresAt);
return {
bindingId: resolveBindingKey({
accountId: record.accountId,
conversationId: record.conversationId,
}),
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "feishu",
accountId: record.accountId,
conversationId: record.conversationId,
parentConversationId: record.parentConversationId,
},
status: "active",
boundAt: record.boundAt,
expiresAt,
metadata: {
agentId: record.agentId,
label: record.label,
boundBy: record.boundBy,
deliveryTo: record.deliveryTo,
deliveryThreadId: record.deliveryThreadId,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs: defaults.idleTimeoutMs,
maxAgeMs: defaults.maxAgeMs,
},
};
}
export function createFeishuThreadBindingManager(params: {
accountId?: string;
cfg: OpenClawConfig;
}): FeishuThreadBindingManager {
const accountId = normalizeAccountId(params.accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing) {
return existing;
}
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
cfg: params.cfg,
channel: "feishu",
accountId,
});
const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
cfg: params.cfg,
channel: "feishu",
accountId,
});
const manager: FeishuThreadBindingManager = {
accountId,
getByConversationId: (conversationId) =>
BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })),
listBySessionKey: (targetSessionKey) =>
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
),
bindConversation: ({
conversationId,
parentConversationId,
targetKind,
targetSessionKey,
metadata,
}) => {
const normalizedConversationId = conversationId.trim();
if (!normalizedConversationId || !targetSessionKey.trim()) {
return null;
}
const now = Date.now();
const record: FeishuThreadBindingRecord = {
accountId,
conversationId: normalizedConversationId,
parentConversationId: parentConversationId?.trim() || undefined,
deliveryTo:
typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim()
? metadata.deliveryTo.trim()
: undefined,
deliveryThreadId:
typeof metadata?.deliveryThreadId === "string" && metadata.deliveryThreadId.trim()
? metadata.deliveryThreadId.trim()
: undefined,
targetKind: toFeishuTargetKind(targetKind),
targetSessionKey: targetSessionKey.trim(),
agentId:
typeof metadata?.agentId === "string" && metadata.agentId.trim()
? metadata.agentId.trim()
: resolveAgentIdFromSessionKey(targetSessionKey),
label:
typeof metadata?.label === "string" && metadata.label.trim()
? metadata.label.trim()
: undefined,
boundBy:
typeof metadata?.boundBy === "string" && metadata.boundBy.trim()
? metadata.boundBy.trim()
: undefined,
boundAt: now,
lastActivityAt: now,
};
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
record,
);
return record;
},
touchConversation: (conversationId, at = Date.now()) => {
const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
if (!existingRecord) {
return null;
}
const updated = { ...existingRecord, lastActivityAt: at };
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated);
return updated;
},
unbindConversation: (conversationId) => {
const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
if (!existingRecord) {
return null;
}
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
return existingRecord;
},
unbindBySessionKey: (targetSessionKey) => {
const removed: FeishuThreadBindingRecord[] = [];
for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) {
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
continue;
}
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(
resolveBindingKey({ accountId, conversationId: record.conversationId }),
);
removed.push(record);
}
return removed;
},
stop: () => {
for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) {
if (key.startsWith(`${accountId}:`)) {
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
}
}
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
},
};
registerSessionBindingAdapter({
channel: "feishu",
accountId,
capabilities: {
placements: ["current"],
},
bind: async (input) => {
if (input.conversation.channel !== "feishu" || input.placement === "child") {
return null;
}
const bound = manager.bindConversation({
conversationId: input.conversation.conversationId,
parentConversationId: input.conversation.parentConversationId,
targetKind: input.targetKind,
targetSessionKey: input.targetSessionKey,
metadata: input.metadata,
});
return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null;
},
listBySession: (targetSessionKey) =>
manager
.listBySessionKey(targetSessionKey)
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
resolveByConversation: (ref) => {
if (ref.channel !== "feishu") {
return null;
}
const found = manager.getByConversationId(ref.conversationId);
return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null;
},
touch: (bindingId, at) => {
const conversationId = resolveThreadBindingConversationIdFromBindingId({
accountId,
bindingId,
});
if (conversationId) {
manager.touchConversation(conversationId, at);
}
},
unbind: async (input) => {
if (input.targetSessionKey?.trim()) {
return manager
.unbindBySessionKey(input.targetSessionKey.trim())
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
}
const conversationId = resolveThreadBindingConversationIdFromBindingId({
accountId,
bindingId: input.bindingId,
});
if (!conversationId) {
return [];
}
const removed = manager.unbindConversation(conversationId);
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
},
});
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
return manager;
}
export function getFeishuThreadBindingManager(
accountId?: string,
): FeishuThreadBindingManager | null {
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
}
export const __testing = {
resetFeishuThreadBindingsForTests() {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
},
};

View File

@@ -1,97 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
verifyIdToken: vi.fn(),
}));
vi.mock("google-auth-library", () => ({
GoogleAuth: class {},
OAuth2Client: class {
verifyIdToken = mocks.verifyIdToken;
},
}));
const { verifyGoogleChatRequest } = await import("./auth.js");
function mockTicket(payload: Record<string, unknown>) {
mocks.verifyIdToken.mockResolvedValue({
getPayload: () => payload,
});
}
describe("verifyGoogleChatRequest", () => {
beforeEach(() => {
mocks.verifyIdToken.mockReset();
});
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
mockTicket({
email: "chat@system.gserviceaccount.com",
email_verified: true,
});
await expect(
verifyGoogleChatRequest({
bearer: "token",
audienceType: "app-url",
audience: "https://example.com/googlechat",
}),
).resolves.toEqual({ ok: true });
});
it("rejects add-on tokens when no principal binding is configured", async () => {
mockTicket({
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
email_verified: true,
sub: "principal-1",
});
await expect(
verifyGoogleChatRequest({
bearer: "token",
audienceType: "app-url",
audience: "https://example.com/googlechat",
}),
).resolves.toEqual({
ok: false,
reason: "missing add-on principal binding",
});
});
it("accepts add-on tokens only when the bound principal matches", async () => {
mockTicket({
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
email_verified: true,
sub: "principal-1",
});
await expect(
verifyGoogleChatRequest({
bearer: "token",
audienceType: "app-url",
audience: "https://example.com/googlechat",
expectedAddOnPrincipal: "principal-1",
}),
).resolves.toEqual({ ok: true });
});
it("rejects add-on tokens when the bound principal does not match", async () => {
mockTicket({
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
email_verified: true,
sub: "principal-2",
});
await expect(
verifyGoogleChatRequest({
bearer: "token",
audienceType: "app-url",
audience: "https://example.com/googlechat",
expectedAddOnPrincipal: "principal-1",
}),
).resolves.toEqual({
ok: false,
reason: "unexpected add-on principal: principal-2",
});
});
});

View File

@@ -94,7 +94,6 @@ export async function verifyGoogleChatRequest(params: {
bearer?: string | null;
audienceType?: GoogleChatAudienceType | null;
audience?: string | null;
expectedAddOnPrincipal?: string | null;
}): Promise<{ ok: boolean; reason?: string }> {
const bearer = params.bearer?.trim();
if (!bearer) {
@@ -113,32 +112,10 @@ export async function verifyGoogleChatRequest(params: {
audience,
});
const payload = ticket.getPayload();
const email = String(payload?.email ?? "")
.trim()
.toLowerCase();
if (!payload?.email_verified) {
return { ok: false, reason: "email not verified" };
}
if (email === CHAT_ISSUER) {
return { ok: true };
}
if (!ADDON_ISSUER_PATTERN.test(email)) {
return { ok: false, reason: `invalid issuer: ${email}` };
}
const expectedAddOnPrincipal = params.expectedAddOnPrincipal?.trim().toLowerCase();
if (!expectedAddOnPrincipal) {
return { ok: false, reason: "missing add-on principal binding" };
}
const tokenPrincipal = String(payload?.sub ?? "")
.trim()
.toLowerCase();
if (!tokenPrincipal || tokenPrincipal !== expectedAddOnPrincipal) {
return {
ok: false,
reason: `unexpected add-on principal: ${tokenPrincipal || "<missing>"}`,
};
}
return { ok: true };
const email = payload?.email ?? "";
const ok =
payload?.email_verified && (email === CHAT_ISSUER || ADDON_ISSUER_PATTERN.test(email));
return ok ? { ok: true } : { ok: false, reason: `invalid issuer: ${email}` };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
}

View File

@@ -21,9 +21,6 @@ function extractBearerToken(header: unknown): string {
: "";
}
const ADD_ON_PREAUTH_MAX_BYTES = 16 * 1024;
const ADD_ON_PREAUTH_TIMEOUT_MS = 3_000;
type ParsedGoogleChatInboundPayload =
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
| { ok: false };
@@ -115,12 +112,6 @@ export function createGoogleChatWebhookRequestHandler(params: {
req,
res,
profile,
...(profile === "pre-auth"
? {
maxBytes: ADD_ON_PREAUTH_MAX_BYTES,
timeoutMs: ADD_ON_PREAUTH_TIMEOUT_MS,
}
: {}),
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
@@ -141,7 +132,6 @@ export function createGoogleChatWebhookRequestHandler(params: {
bearer: headerBearer,
audienceType: target.audienceType,
audience: target.audience,
expectedAddOnPrincipal: target.account.config.appPrincipal,
});
return verification.ok;
},
@@ -176,7 +166,6 @@ export function createGoogleChatWebhookRequestHandler(params: {
bearer: parsed.addOnBearerToken,
audienceType: target.audienceType,
audience: target.audience,
expectedAddOnPrincipal: target.account.config.appPrincipal,
});
return verification.ok;
},

View File

@@ -738,37 +738,6 @@ describe("createMattermostInteractionHandler", () => {
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("blocks button dispatch when the sender is not allowed for the action", async () => {
const { context, token } = createActionContext();
const dispatchButtonClick = vi.fn();
const handleInteraction = vi.fn();
const handler = createMattermostInteractionHandler({
client: {
request: async (_path: string, init?: { method?: string }) =>
init?.method === "PUT" ? { id: "post-1" } : createActionPost(),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
authorizeButtonClick: async () => ({
ok: false,
response: {
ephemeral_text: "blocked",
},
}),
handleInteraction,
dispatchButtonClick,
});
const res = await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
expect(res.statusCode).toBe(200);
expect(res.body).toContain("blocked");
expect(handleInteraction).not.toHaveBeenCalled();
expect(dispatchButtonClick).not.toHaveBeenCalled();
});
it("forwards fetched post threading metadata to session and button callbacks", async () => {
const enqueueSystemEvent = vi.fn();
setMattermostRuntime({

View File

@@ -37,10 +37,6 @@ export type MattermostInteractionResponse = {
ephemeral_text?: string;
};
export type MattermostInteractionAuthorizationResult =
| { ok: true }
| { ok: false; statusCode?: number; response?: MattermostInteractionResponse };
export type MattermostInteractiveButtonInput = {
id?: string;
callback_data?: string;
@@ -408,10 +404,6 @@ export function createMattermostInteractionHandler(params: {
context: Record<string, unknown>;
post: MattermostPost;
}) => Promise<MattermostInteractionResponse | null>;
authorizeButtonClick?: (opts: {
payload: MattermostInteractionPayload;
post: MattermostPost;
}) => Promise<MattermostInteractionAuthorizationResult>;
dispatchButtonClick?: (opts: {
channelId: string;
userId: string;
@@ -574,33 +566,6 @@ export function createMattermostInteractionHandler(params: {
`post=${payload.post_id} channel=${payload.channel_id}`,
);
if (params.authorizeButtonClick) {
try {
const authorization = await params.authorizeButtonClick({
payload,
post: originalPost,
});
if (!authorization.ok) {
res.statusCode = authorization.statusCode ?? 200;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify(
authorization.response ?? {
ephemeral_text: "You are not allowed to use this action here.",
},
),
);
return;
}
} catch (err) {
log?.(`mattermost interaction: authorization failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Interaction authorization failed" }));
return;
}
}
if (params.handleInteraction) {
try {
const response = await params.handleInteraction({

View File

@@ -567,45 +567,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
trustedProxies: cfg.gateway?.trustedProxies,
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
handleInteraction: handleModelPickerInteraction,
authorizeButtonClick: async ({ payload, post }) => {
const channelInfo = await resolveChannelInfo(payload.channel_id);
const isDirect = channelInfo?.type?.trim().toUpperCase() === "D";
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
});
const decision = authorizeMattermostCommandInvocation({
account,
cfg,
senderId: payload.user_id,
senderName: payload.user_name ?? "",
channelId: payload.channel_id,
channelInfo,
storeAllowFrom: isDirect
? await readStoreAllowFromForDmPolicy({
provider: "mattermost",
accountId: account.accountId,
dmPolicy: account.config.dmPolicy ?? "pairing",
readStore: pairing.readStoreForDmPolicy,
})
: undefined,
allowTextCommands,
hasControlCommand: false,
});
if (decision.ok) {
return { ok: true };
}
return {
ok: false,
response: {
update: {
message: post.message ?? "",
props: post.props as Record<string, unknown> | undefined,
},
ephemeral_text: `OpenClaw ignored this action for ${decision.roomLabel}.`,
},
};
},
resolveSessionKey: async ({ channelId, userId, post }) => {
const channelInfo = await resolveChannelInfo(channelId);
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);

View File

@@ -1,7 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { PassThrough } from "node:stream";
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { ResolvedMattermostAccount } from "./accounts.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
@@ -9,7 +9,6 @@ function createRequest(params: {
method?: string;
body?: string;
contentType?: string;
autoEnd?: boolean;
}): IncomingMessage {
const req = new PassThrough();
const incoming = req as unknown as IncomingMessage;
@@ -21,9 +20,7 @@ function createRequest(params: {
if (params.body) {
req.write(params.body);
}
if (params.autoEnd !== false) {
req.end();
}
req.end();
});
return incoming;
}
@@ -131,27 +128,4 @@ describe("slash-http", () => {
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("returns 408 when the request body stalls", async () => {
vi.useFakeTimers();
try {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
});
const req = createRequest({ autoEnd: false });
const response = createResponse();
const pending = handler(req, response.res);
await vi.advanceTimersByTimeAsync(5_000);
await pending;
expect(response.res.statusCode).toBe(408);
expect(response.getBody()).toBe("Request body timeout");
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -10,9 +10,7 @@ import {
buildModelsProviderData,
createReplyPrefixOptions,
createTypingCallbacks,
isRequestBodyLimitError,
logTypingFailure,
readRequestBodyWithLimit,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
@@ -56,16 +54,24 @@ type SlashHttpHandlerParams = {
log?: (msg: string) => void;
};
const MAX_BODY_BYTES = 64 * 1024;
const BODY_READ_TIMEOUT_MS = 5_000;
/**
* Read the full request body as a string.
*/
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
return readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs: BODY_READ_TIMEOUT_MS,
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > maxBytes) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
@@ -209,6 +215,8 @@ async function authorizeSlashInvocation(params: {
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
const { account, cfg, runtime, commandTokens, triggerMap, log } = params;
const MAX_BODY_BYTES = 64 * 1024; // 64KB
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== "POST") {
res.statusCode = 405;
@@ -220,12 +228,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
let body: string;
try {
body = await readBody(req, MAX_BODY_BYTES);
} catch (error) {
if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) {
res.statusCode = 408;
res.end("Request body timeout");
return;
}
} catch {
res.statusCode = 413;
res.end("Payload Too Large");
return;

View File

@@ -269,7 +269,6 @@ export async function monitorMSTeamsProvider(
// Create Express server
const expressApp = express.default();
expressApp.use(authorizeJWT(authConfig));
expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES }));
expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => {
if (err && typeof err === "object" && "status" in err && err.status === 413) {
@@ -278,6 +277,7 @@ export async function monitorMSTeamsProvider(
}
next(err);
});
expressApp.use(authorizeJWT(authConfig));
// Set up the messages endpoint - use configured path and /api/messages as fallback
const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";

View File

@@ -81,77 +81,4 @@ describe("nextcloud-talk inbound authz", () => {
});
expect(buildMentionRegexes).not.toHaveBeenCalled();
});
it("matches group rooms by token instead of colliding room names", async () => {
const readAllowFromStore = vi.fn(async () => []);
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
setNextcloudTalkRuntime({
channel: {
pairing: {
readAllowFromStore,
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns: () => false,
},
},
} as unknown as PluginRuntime);
const message: NextcloudTalkInboundMessage = {
messageId: "m-2",
roomToken: "room-attacker",
roomName: "Room Trusted",
senderId: "trusted-user",
senderName: "Trusted User",
text: "hello",
mediaType: "text/plain",
timestamp: Date.now(),
isGroupChat: true,
};
const account: ResolvedNextcloudTalkAccount = {
accountId: "default",
enabled: true,
baseUrl: "",
secret: "",
secretSource: "none",
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: ["trusted-user"],
rooms: {
"room-trusted": {
enabled: true,
},
},
},
};
await handleNextcloudTalkInbound({
message,
account,
config: {
channels: {
"nextcloud-talk": {
groupPolicy: "allowlist",
groupAllowFrom: ["trusted-user"],
},
},
},
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv,
});
expect(buildMentionRegexes).not.toHaveBeenCalled();
});
});

View File

@@ -114,6 +114,7 @@ export async function handleNextcloudTalkInbound(params: {
const roomMatch = resolveNextcloudTalkRoomMatch({
rooms: account.config.rooms,
roomToken,
roomName,
});
const roomConfig = roomMatch.roomConfig;
if (isGroup && !roomMatch.allowed) {

View File

@@ -25,8 +25,6 @@ const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
const HEALTH_PATH = "/healthz";
const WEBHOOK_ERRORS = {
missingSignatureHeaders: "Missing signature headers",
@@ -173,10 +171,8 @@ export function readNextcloudTalkWebhookBody(
maxBodyBytes: number,
): Promise<string> {
return readRequestBodyWithLimit(req, {
// This read happens before signature verification, so keep the unauthenticated
// body budget bounded even if the operator-configured post-parse limit is larger.
maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
timeoutMs: PREAUTH_WEBHOOK_BODY_TIMEOUT_MS,
maxBytes: maxBodyBytes,
timeoutMs: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS,
});
}

View File

@@ -57,10 +57,16 @@ export type NextcloudTalkRoomMatch = {
export function resolveNextcloudTalkRoomMatch(params: {
rooms?: Record<string, NextcloudTalkRoomConfig>;
roomToken: string;
roomName?: string | null;
}): NextcloudTalkRoomMatch {
const rooms = params.rooms ?? {};
const allowlistConfigured = Object.keys(rooms).length > 0;
const roomCandidates = buildChannelKeyCandidates(params.roomToken);
const roomName = params.roomName?.trim() || undefined;
const roomCandidates = buildChannelKeyCandidates(
params.roomToken,
roomName,
roomName ? normalizeChannelSlug(roomName) : undefined,
);
const match = resolveChannelEntryMatchWithFallback({
entries: rooms,
keys: roomCandidates,
@@ -95,9 +101,11 @@ export function resolveNextcloudTalkGroupToolPolicy(
if (!roomToken) {
return undefined;
}
const roomName = params.groupChannel?.trim() || undefined;
const match = resolveNextcloudTalkRoomMatch({
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
roomToken,
roomName,
});
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
}

View File

@@ -16,8 +16,6 @@ import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./type
// One rate limiter per account, created lazily
const rateLimiters = new Map<string, RateLimiter>();
const PREAUTH_MAX_BODY_BYTES = 64 * 1024;
const PREAUTH_BODY_TIMEOUT_MS = 5_000;
function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
let rl = rateLimiters.get(account.accountId);
@@ -51,8 +49,8 @@ async function readBody(req: IncomingMessage): Promise<
> {
try {
const body = await readRequestBodyWithLimit(req, {
maxBytes: PREAUTH_MAX_BODY_BYTES,
timeoutMs: PREAUTH_BODY_TIMEOUT_MS,
maxBytes: 1_048_576,
timeoutMs: 30_000,
});
return { ok: true, body };
} catch (err) {

View File

@@ -403,30 +403,3 @@ describe("telegramPlugin duplicate token guard", () => {
);
});
});
describe("telegramPlugin outbound sendPayload forceDocument", () => {
it("forwards forceDocument to the underlying send call when channelData is present", async () => {
const sendMessageTelegram = installSendMessageRuntime(
vi.fn(async () => ({ messageId: "tg-fd" })),
);
await telegramPlugin.outbound!.sendPayload!({
cfg: createCfg(),
to: "12345",
text: "",
payload: {
text: "here is an image",
mediaUrls: ["https://example.com/photo.png"],
channelData: { telegram: {} },
},
accountId: "ops",
forceDocument: true,
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"12345",
expect.any(String),
expect.objectContaining({ forceDocument: true }),
);
});
});

View File

@@ -96,7 +96,6 @@ function buildTelegramSendOptions(params: {
replyToId?: string | null;
threadId?: string | number | null;
silent?: boolean | null;
forceDocument?: boolean | null;
}): TelegramSendOptions {
return {
verbose: false,
@@ -107,7 +106,6 @@ function buildTelegramSendOptions(params: {
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined,
silent: params.silent ?? undefined,
forceDocument: params.forceDocument ?? undefined,
};
}
@@ -388,7 +386,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId,
threadId,
silent,
forceDocument,
}) => {
const send =
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
@@ -404,7 +401,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId,
threadId,
silent,
forceDocument,
}),
});
return { channel: "telegram", ...result };

View File

@@ -5,12 +5,12 @@ import { getSessionBindingService } from "../../../src/infra/outbound/session-bi
import {
buildAgentSessionKey,
deriveLastRoutePolicy,
pickFirstExistingAgentId,
resolveAgentRoute,
} from "../../../src/routing/resolve-route.js";
import {
buildAgentMainSessionKey,
resolveAgentIdFromSessionKey,
sanitizeAgentId,
} from "../../../src/routing/session-key.js";
import {
buildTelegramGroupPeerId,
@@ -56,9 +56,7 @@ export function resolveTelegramConversationRoute(params: {
const rawTopicAgentId = params.topicAgentId?.trim();
if (rawTopicAgentId) {
// Preserve the configured topic agent ID so topic-bound sessions stay stable
// even when that agent is not present in the current config snapshot.
const topicAgentId = sanitizeAgentId(rawTopicAgentId);
const topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId);
route = {
...route,
agentId: topicAgentId,

View File

@@ -512,146 +512,6 @@ function sliceLinkSpans(
});
}
function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
return {
text: ir.text.slice(start, end),
styles: sliceStyleSpans(ir.styles, start, end),
links: sliceLinkSpans(ir.links, start, end),
};
}
function mergeAdjacentStyleSpans(styles: MarkdownIR["styles"]): MarkdownIR["styles"] {
const merged: MarkdownIR["styles"] = [];
for (const span of styles) {
const last = merged.at(-1);
if (last && last.style === span.style && span.start <= last.end) {
last.end = Math.max(last.end, span.end);
continue;
}
merged.push({ ...span });
}
return merged;
}
function mergeAdjacentLinkSpans(links: MarkdownIR["links"]): MarkdownIR["links"] {
const merged: MarkdownIR["links"] = [];
for (const link of links) {
const last = merged.at(-1);
if (last && last.href === link.href && link.start <= last.end) {
last.end = Math.max(last.end, link.end);
continue;
}
merged.push({ ...link });
}
return merged;
}
function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR {
const offset = left.text.length;
return {
text: left.text + right.text,
styles: mergeAdjacentStyleSpans([
...left.styles,
...right.styles.map((span) => ({
...span,
start: span.start + offset,
end: span.end + offset,
})),
]),
links: mergeAdjacentLinkSpans([
...left.links,
...right.links.map((link) => ({
...link,
start: link.start + offset,
end: link.end + offset,
})),
]),
};
}
function renderTelegramChunkHtml(ir: MarkdownIR): string {
return wrapFileReferencesInHtml(renderTelegramHtml(ir));
}
function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: number): number {
const maxEnd = Math.min(text.length, start + limit);
if (maxEnd >= text.length) {
return text.length;
}
let lastOutsideParenNewlineBreak = -1;
let lastOutsideParenWhitespaceBreak = -1;
let lastOutsideParenWhitespaceRunStart = -1;
let lastAnyNewlineBreak = -1;
let lastAnyWhitespaceBreak = -1;
let lastAnyWhitespaceRunStart = -1;
let parenDepth = 0;
let sawNonWhitespace = false;
for (let index = start; index < maxEnd; index += 1) {
const char = text[index];
if (char === "(") {
sawNonWhitespace = true;
parenDepth += 1;
continue;
}
if (char === ")" && parenDepth > 0) {
sawNonWhitespace = true;
parenDepth -= 1;
continue;
}
if (!/\s/.test(char)) {
sawNonWhitespace = true;
continue;
}
if (!sawNonWhitespace) {
continue;
}
if (char === "\n") {
lastAnyNewlineBreak = index + 1;
if (parenDepth === 0) {
lastOutsideParenNewlineBreak = index + 1;
}
continue;
}
const whitespaceRunStart =
index === start || !/\s/.test(text[index - 1] ?? "") ? index : lastAnyWhitespaceRunStart;
lastAnyWhitespaceBreak = index + 1;
lastAnyWhitespaceRunStart = whitespaceRunStart;
if (parenDepth === 0) {
lastOutsideParenWhitespaceBreak = index + 1;
lastOutsideParenWhitespaceRunStart = whitespaceRunStart;
}
}
const resolveWhitespaceBreak = (breakIndex: number, runStart: number): number => {
if (breakIndex <= start) {
return breakIndex;
}
if (runStart <= start) {
return breakIndex;
}
return /\s/.test(text[breakIndex] ?? "") ? runStart : breakIndex;
};
if (lastOutsideParenNewlineBreak > start) {
return lastOutsideParenNewlineBreak;
}
if (lastOutsideParenWhitespaceBreak > start) {
return resolveWhitespaceBreak(
lastOutsideParenWhitespaceBreak,
lastOutsideParenWhitespaceRunStart,
);
}
if (lastAnyNewlineBreak > start) {
return lastAnyNewlineBreak;
}
if (lastAnyWhitespaceBreak > start) {
return resolveWhitespaceBreak(lastAnyWhitespaceBreak, lastAnyWhitespaceRunStart);
}
return maxEnd;
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];
@@ -663,7 +523,7 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
const chunks: MarkdownIR[] = [];
let cursor = 0;
while (cursor < ir.text.length) {
const end = findMarkdownIRPreservedSplitIndex(ir.text, cursor, normalizedLimit);
const end = Math.min(ir.text.length, cursor + normalizedLimit);
chunks.push({
text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end),
@@ -674,98 +534,32 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
return chunks;
}
function coalesceWhitespaceOnlyMarkdownIRChunks(chunks: MarkdownIR[], limit: number): MarkdownIR[] {
const coalesced: MarkdownIR[] = [];
let index = 0;
while (index < chunks.length) {
const chunk = chunks[index];
if (!chunk) {
index += 1;
continue;
}
if (chunk.text.trim().length > 0) {
coalesced.push(chunk);
index += 1;
continue;
}
const prev = coalesced.at(-1);
const next = chunks[index + 1];
const chunkLength = chunk.text.length;
const canMergePrev = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
const canMergeNext = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
if (prev) {
const mergedPrev = mergeMarkdownIRChunks(prev, chunk);
if (canMergePrev(mergedPrev)) {
coalesced[coalesced.length - 1] = mergedPrev;
index += 1;
continue;
}
}
if (next) {
const mergedNext = mergeMarkdownIRChunks(chunk, next);
if (canMergeNext(mergedNext)) {
chunks[index + 1] = mergedNext;
index += 1;
continue;
}
}
if (prev && next) {
for (let prefixLength = chunkLength - 1; prefixLength >= 1; prefixLength -= 1) {
const prefix = sliceMarkdownIR(chunk, 0, prefixLength);
const suffix = sliceMarkdownIR(chunk, prefixLength, chunkLength);
const mergedPrev = mergeMarkdownIRChunks(prev, prefix);
const mergedNext = mergeMarkdownIRChunks(suffix, next);
if (canMergePrev(mergedPrev) && canMergeNext(mergedNext)) {
coalesced[coalesced.length - 1] = mergedPrev;
chunks[index + 1] = mergedNext;
break;
}
}
}
index += 1;
}
return coalesced;
}
function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR,
limit: number,
): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit);
const finalized: MarkdownIR[] = [];
const rendered: TelegramFormattedChunk[] = [];
while (pending.length > 0) {
const chunk = pending.shift();
if (!chunk) {
continue;
}
const html = renderTelegramChunkHtml(chunk);
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk));
if (html.length <= normalizedLimit || chunk.text.length <= 1) {
finalized.push(chunk);
rendered.push({ html, text: chunk.text });
continue;
}
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is.
finalized.push(chunk);
rendered.push({ html, text: chunk.text });
continue;
}
pending.unshift(...split);
}
return coalesceWhitespaceOnlyMarkdownIRChunks(finalized, normalizedLimit).map((chunk) => ({
html: renderTelegramChunkHtml(chunk),
text: chunk.text,
}));
return rendered;
}
export function markdownToTelegramChunks(

View File

@@ -174,35 +174,6 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true);
});
it("prefers word boundaries when html-limit retry splits formatted prose", () => {
const input = "**Which of these**";
const chunks = markdownToTelegramChunks(input, 16);
expect(chunks.map((chunk) => chunk.text)).toEqual(["Which of ", "these"]);
expect(chunks.every((chunk) => chunk.html.length <= 16)).toBe(true);
});
it("falls back to in-paren word boundaries when the parenthesis is unbalanced", () => {
const input = "**foo (bar baz qux quux**";
const chunks = markdownToTelegramChunks(input, 20);
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]);
expect(chunks.every((chunk) => chunk.html.length <= 20)).toBe(true);
});
it("does not emit whitespace-only chunks during html-limit retry splitting", () => {
const input = "**ab <<**";
const chunks = markdownToTelegramChunks(input, 11);
expect(chunks.map((chunk) => chunk.text).join("")).toBe("ab <<");
expect(chunks.every((chunk) => chunk.text.trim().length > 0)).toBe(true);
expect(chunks.every((chunk) => chunk.html.length <= 11)).toBe(true);
});
it("preserves paragraph separators when retry chunking produces whitespace-only spans", () => {
const input = "ab\n\n<<";
const chunks = markdownToTelegramChunks(input, 6);
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 6)).toBe(true);
});
});
describe("edge cases", () => {

View File

@@ -141,7 +141,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
deps,
replyToId,
threadId,
forceDocument,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
@@ -157,7 +156,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
baseOpts: {
...baseOpts,
mediaLocalRoots,
forceDocument: forceDocument ?? false,
},
});
return { channel: "telegram", ...result };

View File

@@ -301,7 +301,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`,
);
}
if (currentSettings.dmAllowlist !== undefined) {
if (currentSettings.dmAllowlist?.length) {
effectiveDmAllowlist = currentSettings.dmAllowlist;
runtime.log?.(
`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`,
@@ -322,7 +322,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
`[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`,
);
}
if (currentSettings.groupInviteAllowlist !== undefined) {
if (currentSettings.groupInviteAllowlist?.length) {
effectiveGroupInviteAllowlist = currentSettings.groupInviteAllowlist;
runtime.log?.(
`[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`,
@@ -1176,14 +1176,17 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
return;
}
// Resolve any cited/quoted messages first
const citedContent = await resolveAllCites(content.content);
const rawText = extractMessageText(content.content);
if (!rawText.trim()) {
const messageText = citedContent + rawText;
if (!messageText.trim()) {
return;
}
cacheMessage(nest, {
author: senderShip,
content: rawText,
content: messageText,
timestamp: content.sent || Date.now(),
id: messageId,
});
@@ -1197,7 +1200,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Check if we should respond:
// 1. Direct mention always triggers response
// 2. Thread replies where we've participated - respond if relevant (let agent decide)
const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined);
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
const inParticipatedThread =
isThreadReply && parentId && participatedThreads.has(String(parentId));
@@ -1224,10 +1227,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
type: "channel",
requestingShip: senderShip,
channelNest: nest,
messagePreview: rawText.substring(0, 100),
messagePreview: messageText.substring(0, 100),
originalMessage: {
messageId: messageId ?? "",
messageText: rawText,
messageText,
messageContent: content.content,
timestamp: content.sent || Date.now(),
parentId: parentId ?? undefined,
@@ -1245,10 +1248,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
}
}
// Resolve quoted content only after the sender passed channel authorization.
const citedContent = await resolveAllCites(content.content);
const messageText = citedContent + rawText;
const parsed = parseChannelNest(nest);
await processMessage({
messageId: messageId ?? "",
@@ -1366,15 +1365,15 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
);
}
// Resolve any cited/quoted messages first
const citedContent = await resolveAllCites(essay.content);
const rawText = extractMessageText(essay.content);
if (!rawText.trim()) {
const messageText = citedContent + rawText;
if (!messageText.trim()) {
return;
}
const citedContent = await resolveAllCites(essay.content);
const resolvedMessageText = citedContent + rawText;
// Check if this is the owner sending an approval response
const messageText = rawText;
if (isOwner(senderShip) && isApprovalResponse(messageText)) {
const handled = await handleApprovalResponse(messageText);
if (handled) {
@@ -1398,7 +1397,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText: resolvedMessageText,
messageText,
messageContent: essay.content,
isGroup: false,
timestamp: essay.sent || Date.now(),
@@ -1431,7 +1430,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText: resolvedMessageText,
messageText,
messageContent: essay.content, // Pass raw content for media extraction
isGroup: false,
timestamp: essay.sent || Date.now(),
@@ -1525,7 +1524,8 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Update DM allowlist
if (newSettings.dmAllowlist !== undefined) {
effectiveDmAllowlist = newSettings.dmAllowlist;
effectiveDmAllowlist =
newSettings.dmAllowlist.length > 0 ? newSettings.dmAllowlist : account.dmAllowlist;
runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
}
@@ -1551,7 +1551,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Update group invite allowlist
if (newSettings.groupInviteAllowlist !== undefined) {
effectiveGroupInviteAllowlist = newSettings.groupInviteAllowlist;
effectiveGroupInviteAllowlist =
newSettings.groupInviteAllowlist.length > 0
? newSettings.groupInviteAllowlist
: account.groupInviteAllowlist;
runtime.log?.(
`[tlon] Settings: groupInviteAllowlist updated to ${effectiveGroupInviteAllowlist.join(", ")}`,
);

View File

@@ -160,13 +160,6 @@ describe("checkTwitchAccessControl", () => {
});
});
it("blocks everyone when allowFrom is explicitly empty", () => {
expectAllowFromBlocked({
allowFrom: [],
reason: "allowFrom",
});
});
it("blocks messages without userId", () => {
expectAllowFromBlocked({
allowFrom: ["123456"],

View File

@@ -48,14 +48,8 @@ export function checkTwitchAccessControl(params: {
}
}
if (account.allowFrom !== undefined) {
if (account.allowFrom && account.allowFrom.length > 0) {
const allowFrom = account.allowFrom;
if (allowFrom.length === 0) {
return {
allowed: false,
reason: "sender is not in allowFrom allowlist",
};
}
const senderId = message.userId;
if (!senderId) {

View File

@@ -24,7 +24,7 @@ import {
resolveWhatsAppGroupIntroHint,
resolveWhatsAppGroupToolPolicy,
resolveWhatsAppHeartbeatRecipients,
resolveWhatsAppMentionStripRegexes,
resolveWhatsAppMentionStripPatterns,
WhatsAppConfigSchema,
type ChannelMessageActionName,
type ChannelPlugin,
@@ -214,7 +214,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
},
mentions: {
stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx),
stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx),
},
commands: {
enforceOwnerForCommands: true,

View File

@@ -413,13 +413,7 @@ export async function monitorWebInbox(options: {
// If this is history/offline catch-up, mark read above but skip auto-reply.
if (upsert.type === "append") {
const APPEND_RECENT_GRACE_MS = 60_000;
const msgTsRaw = msg.messageTimestamp;
const msgTsNum = msgTsRaw != null ? Number(msgTsRaw) : NaN;
const msgTsMs = Number.isFinite(msgTsNum) ? msgTsNum * 1000 : 0;
if (msgTsMs < connectedAtMs - APPEND_RECENT_GRACE_MS) {
continue;
}
continue;
}
const enriched = await enrichInboundMessage(msg);

View File

@@ -1,11 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import {
createWaSocket,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js";
vi.mock("./session.js", () => {
const createWaSocket = vi.fn(
@@ -22,13 +17,11 @@ vi.mock("./session.js", () => {
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
(err as { status?: number })?.status,
);
const webAuthExists = vi.fn(async () => false);
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
createWaSocket,
waitForWaConnection,
@@ -37,7 +30,6 @@ vi.mock("./session.js", () => {
webAuthExists,
readWebSelfId,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
};
});
@@ -47,43 +39,22 @@ vi.mock("./qr-image.js", () => ({
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const logoutWebMock = vi.mocked(logoutWeb);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("login-qr", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restarts login once on status 515 and completes", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock
// Baileys v7 wraps the error: { error: BoomError(515) }
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockRejectedValueOnce({ output: { statusCode: 515 } })
.mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
const resultPromise = waitForWebLogin({ timeoutMs: 5000 });
await flushTasks();
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
releaseCredsFlush?.();
const result = await resultPromise;
const result = await waitForWebLogin({ timeoutMs: 5000 });
expect(result.connected).toBe(true);
expect(createWaSocketMock).toHaveBeenCalledTimes(2);

View File

@@ -12,7 +12,6 @@ import {
getStatusCode,
logoutWeb,
readWebSelfId,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
webAuthExists,
} from "./session.js";
@@ -86,10 +85,9 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
}
login.restartAttempted = true;
runtime.log(
info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"),
);
closeSocket(login.sock);
await waitForCredsSaveQueueWithTimeout(login.authDir);
try {
const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir,

View File

@@ -4,12 +4,7 @@ import path from "node:path";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loginWeb } from "./login.js";
import {
createWaSocket,
formatError,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
import { createWaSocket, formatError, waitForWaConnection } from "./session.js";
const rmMock = vi.spyOn(fs, "rm");
@@ -40,19 +35,10 @@ vi.mock("./session.js", () => {
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
createWaSocket,
waitForWaConnection,
formatError,
getStatusCode,
waitForCredsSaveQueueWithTimeout,
WA_WEB_AUTH_DIR: authDir,
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? authDir, {
@@ -66,14 +52,8 @@ vi.mock("./session.js", () => {
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const formatErrorMock = vi.mocked(formatError);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("loginWeb coverage", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -85,25 +65,12 @@ describe("loginWeb coverage", () => {
});
it("restarts once when WhatsApp requests code 515", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockRejectedValueOnce({ output: { statusCode: 515 } })
.mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const runtime = { log: vi.fn(), error: vi.fn() } as never;
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(authDir);
releaseCredsFlush?.();
await pendingLogin;
await loginWeb(false, waitForWaConnectionMock as never, runtime);
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
const firstSock = await createWaSocketMock.mock.results[0]?.value;

View File

@@ -5,14 +5,7 @@ import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js";
export async function loginWeb(
verbose: boolean,
@@ -31,17 +24,20 @@ export async function loginWeb(
await wait(sock);
console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) {
const code = getStatusCode(err);
const code =
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
if (code === 515) {
console.log(
info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
info(
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
);
try {
sock.ws?.close();
} catch {
// ignore
}
await waitForCredsSaveQueueWithTimeout(account.authDir);
const retry = await createWaSocket(false, verbose, {
authDir: account.authDir,
});

View File

@@ -254,7 +254,6 @@ describe("web monitor inbox", () => {
it("handles append messages by marking them read but skipping auto-reply", async () => {
const { onMessage, listener, sock } = await openInboxMonitor();
const staleTs = Math.floor(Date.now() / 1000) - 300;
const upsert = {
type: "append",
@@ -266,7 +265,7 @@ describe("web monitor inbox", () => {
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "old message" },
messageTimestamp: staleTs,
messageTimestamp: nowSeconds(),
pushName: "History Sender",
},
],

View File

@@ -1,149 +0,0 @@
import "./monitor-inbox.test-harness.js";
import { describe, expect, it, vi } from "vitest";
import { monitorWebInbox } from "./inbound.js";
import {
DEFAULT_ACCOUNT_ID,
getAuthDir,
getSock,
installWebMonitorInboxUnitTestHooks,
} from "./monitor-inbox.test-harness.js";
describe("append upsert handling (#20952)", () => {
installWebMonitorInboxUnitTestHooks();
type InboxOnMessage = NonNullable<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
async function tick() {
await new Promise((resolve) => setImmediate(resolve));
}
async function startInboxMonitor(onMessage: InboxOnMessage) {
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
return { listener, sock: getSock() };
}
it("processes recent append messages (within 60s of connect)", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp ~5 seconds ago — recent, should be processed.
const recentTs = Math.floor(Date.now() / 1000) - 5;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "hello from group" },
messageTimestamp: recentTs,
pushName: "Tester",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("skips stale append messages (older than 60s before connect)", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp 5 minutes ago — stale history sync, should be skipped.
const staleTs = Math.floor(Date.now() / 1000) - 300;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "old history sync" },
messageTimestamp: staleTs,
pushName: "OldTester",
},
],
});
await tick();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("skips append messages with NaN/non-finite timestamps", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// NaN timestamp should be treated as 0 (stale) and skipped.
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "bad timestamp" },
messageTimestamp: NaN,
pushName: "BadTs",
},
],
});
await tick();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("handles Long-like protobuf timestamps correctly", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Baileys can deliver messageTimestamp as a Long object (from protobufjs).
// Number(longObj) calls valueOf() and returns the numeric value.
const recentTs = Math.floor(Date.now() / 1000) - 5;
const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs };
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "long timestamp" },
messageTimestamp: longLike,
pushName: "LongTs",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("always processes notify messages regardless of timestamp", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Very old timestamp but type=notify — should always be processed.
const oldTs = Math.floor(Date.now() / 1000) - 86400;
sock.ev.emit("messages.upsert", {
type: "notify",
messages: [
{
key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "normal message" },
messageTimestamp: oldTs,
pushName: "User",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
});

View File

@@ -204,62 +204,6 @@ describe("web session", () => {
expect(inFlight).toBe(0);
});
it("lets different authDir queues flush independently", async () => {
let inFlightA = 0;
let inFlightB = 0;
let releaseA: (() => void) | null = null;
let releaseB: (() => void) | null = null;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const gateB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const saveCredsA = vi.fn(async () => {
inFlightA += 1;
await gateA;
inFlightA -= 1;
});
const saveCredsB = vi.fn(async () => {
inFlightB += 1;
await gateB;
inFlightB -= 1;
});
useMultiFileAuthStateMock
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsA,
})
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsB,
});
await createWaSocket(false, false, { authDir: "/tmp/wa-a" });
const sockA = getLastSocket();
await createWaSocket(false, false, { authDir: "/tmp/wa-b" });
const sockB = getLastSocket();
sockA.ev.emit("creds.update", {});
sockB.ev.emit("creds.update", {});
await flushCredsUpdate();
expect(saveCredsA).toHaveBeenCalledTimes(1);
expect(saveCredsB).toHaveBeenCalledTimes(1);
expect(inFlightA).toBe(1);
expect(inFlightB).toBe(1);
(releaseA as (() => void) | null)?.();
(releaseB as (() => void) | null)?.();
await flushCredsUpdate();
await flushCredsUpdate();
expect(inFlightA).toBe(0);
expect(inFlightB).toBe(0);
});
it("rotates creds backup when creds.json is valid JSON", async () => {
const creds = mockCredsJsonSpies("{}");
const backupSuffix = path.join(

View File

@@ -31,24 +31,17 @@ export {
webAuthExists,
} from "./auth-store.js";
// Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
let credsSaveQueue: Promise<void> = Promise.resolve();
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): void {
const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
const next = prev
credsSaveQueue = credsSaveQueue
.then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
})
.finally(() => {
if (credsSaveQueues.get(authDir) === next) credsSaveQueues.delete(authDir);
});
credsSaveQueues.set(authDir, next);
}
async function safeSaveCreds(
@@ -193,37 +186,10 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
export function getStatusCode(err: unknown) {
return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode
(err as { status?: number })?.status
);
}
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
if (authDir) {
return credsSaveQueues.get(authDir) ?? Promise.resolve();
}
return Promise.all(credsSaveQueues.values()).then(() => {});
}
/** Await pending credential saves, but don't hang forever on stalled I/O. */
export async function waitForCredsSaveQueueWithTimeout(
authDir: string,
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
): Promise<void> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
waitForCredsSaveQueue(authDir),
new Promise<void>((resolve) => {
flushTimeout = setTimeout(resolve, timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}
function safeStringify(value: unknown, limit = 800): string {
try {
const seen = new WeakSet();

View File

@@ -4,20 +4,8 @@ export function runWatchMain(params?: {
args: string[],
options: unknown,
) => {
kill?: (signal?: NodeJS.Signals | number) => void;
on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void;
};
createWatcher?: (
paths: string[],
options: {
ignoreInitial: boolean;
ignored: (watchPath: string) => boolean;
},
) => {
on: (event: "add" | "change" | "unlink" | "error", cb: (arg?: unknown) => void) => void;
close?: () => Promise<void> | void;
};
watchPaths?: string[];
process?: NodeJS.Process;
cwd?: string;
args?: string[];

View File

@@ -2,24 +2,16 @@
import { spawn } from "node:child_process";
import process from "node:process";
import { pathToFileURL } from "node:url";
import chokidar from "chokidar";
import { runNodeWatchedPaths } from "./run-node.mjs";
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const isIgnoredWatchPath = (filePath) => {
const normalizedPath = normalizePath(filePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
};
const buildWatchArgs = (args) => [
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
"--watch-preserve-output",
WATCH_NODE_RUNNER,
...args,
];
export async function runWatchMain(params = {}) {
const deps = {
@@ -29,9 +21,6 @@ export async function runWatchMain(params = {}) {
args: params.args ?? process.argv.slice(2),
env: params.env ? { ...params.env } : { ...process.env },
now: params.now ?? Date.now,
createWatcher:
params.createWatcher ?? ((watchPaths, options) => chokidar.watch(watchPaths, options)),
watchPaths: params.watchPaths ?? runNodeWatchedPaths,
};
const childEnv = { ...deps.env };
@@ -42,96 +31,54 @@ export async function runWatchMain(params = {}) {
childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" ");
}
const watchProcess = deps.spawn(deps.process.execPath, buildWatchArgs(deps.args), {
cwd: deps.cwd,
env: childEnv,
stdio: "inherit",
});
let settled = false;
let onSigInt;
let onSigTerm;
const settle = (resolve, code) => {
if (settled) {
return;
}
settled = true;
if (onSigInt) {
deps.process.off("SIGINT", onSigInt);
}
if (onSigTerm) {
deps.process.off("SIGTERM", onSigTerm);
}
resolve(code);
};
return await new Promise((resolve) => {
let settled = false;
let shuttingDown = false;
let restartRequested = false;
let watchProcess = null;
let onSigInt;
let onSigTerm;
const watcher = deps.createWatcher(deps.watchPaths, {
ignoreInitial: true,
ignored: (watchPath) => isIgnoredWatchPath(watchPath),
});
const settle = (code) => {
if (settled) {
return;
}
settled = true;
if (onSigInt) {
deps.process.off("SIGINT", onSigInt);
}
if (onSigTerm) {
deps.process.off("SIGTERM", onSigTerm);
}
watcher.close?.().catch?.(() => {});
resolve(code);
};
const startRunner = () => {
watchProcess = deps.spawn(deps.process.execPath, buildRunnerArgs(deps.args), {
cwd: deps.cwd,
env: childEnv,
stdio: "inherit",
});
watchProcess.on("exit", () => {
watchProcess = null;
if (shuttingDown) {
return;
}
if (restartRequested) {
restartRequested = false;
startRunner();
}
});
};
const requestRestart = (changedPath) => {
if (shuttingDown || isIgnoredWatchPath(changedPath)) {
return;
}
if (!watchProcess) {
startRunner();
return;
}
restartRequested = true;
if (typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
}
};
watcher.on("add", requestRestart);
watcher.on("change", requestRestart);
watcher.on("unlink", requestRestart);
watcher.on("error", () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
}
settle(1);
});
startRunner();
onSigInt = () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
if (typeof watchProcess.kill === "function") {
watchProcess.kill("SIGTERM");
}
settle(130);
settle(resolve, 130);
};
onSigTerm = () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
if (typeof watchProcess.kill === "function") {
watchProcess.kill("SIGTERM");
}
settle(143);
settle(resolve, 143);
};
deps.process.on("SIGINT", onSigInt);
deps.process.on("SIGTERM", onSigTerm);
watchProcess.on("exit", (code, signal) => {
if (signal) {
settle(resolve, 1);
return;
}
settle(resolve, code ?? 1);
});
});
}

View File

@@ -40,11 +40,11 @@ Use `remindctl` to manage Apple Reminders directly from the terminal.
**DON'T use this skill when:**
- Scheduling OpenClaw tasks or alerts → use `cron` tool with systemEvent instead
- Scheduling Clawdbot tasks or alerts → use `cron` tool with systemEvent instead
- Calendar events or appointments → use Apple Calendar
- Project/work task management → use Notion, GitHub Issues, or task queue
- One-time notifications → use `cron` tool for timed alerts
- User says "remind me" but means an OpenClaw alert → clarify first
- User says "remind me" but means a Clawdbot alert → clarify first
## Setup
@@ -112,7 +112,7 @@ Accepted by `--due` and date filters:
User: "Remind me to check on the deploy in 2 hours"
**Ask:** "Do you want this in Apple Reminders (syncs to your phone) or as an OpenClaw alert (I'll message you here)?"
**Ask:** "Do you want this in Apple Reminders (syncs to your phone) or as a Clawdbot alert (I'll message you here)?"
- Apple Reminders → use this skill
- OpenClaw alert → use `cron` tool with systemEvent
- Clawdbot alert → use `cron` tool with systemEvent

View File

@@ -47,7 +47,7 @@ Use `imsg` to read and send iMessage/SMS via macOS Messages.app.
- Slack messages → use `slack` skill
- Group chat management (adding/removing members) → not supported
- Bulk/mass messaging → always confirm with user first
- Replying in current conversation → just reply normally (OpenClaw routes automatically)
- Replying in current conversation → just reply normally (Clawdbot routes automatically)
## Requirements

View File

@@ -366,47 +366,6 @@ describe("resolvePermissionRequest", () => {
expect(prompt).not.toHaveBeenCalled();
});
it("auto-approves safe tools when rawInput is the only identity hint", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: {
toolCallId: "tool-raw-only",
title: "Searching files",
status: "pending",
rawInput: {
name: "search",
query: "foo",
},
},
}),
{ prompt, log: () => {} },
);
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
expect(prompt).not.toHaveBeenCalled();
});
it("prompts when raw input spoofs a safe tool name for a dangerous title", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: {
toolCallId: "tool-exec-spoof",
title: "exec: cat /etc/passwd",
status: "pending",
rawInput: {
command: "cat /etc/passwd",
name: "search",
},
},
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith(undefined, "exec: cat /etc/passwd");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
});
it("prompts for read outside cwd scope", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(

View File

@@ -104,22 +104,7 @@ function resolveToolNameForPermission(params: RequestPermissionRequest): string
const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
const fromTitle = parseToolNameFromTitle(toolCall?.title);
const metaName = fromMeta ? normalizeToolName(fromMeta) : undefined;
const rawInputName = fromRawInput ? normalizeToolName(fromRawInput) : undefined;
const titleName = fromTitle;
if ((fromMeta && !metaName) || (fromRawInput && !rawInputName)) {
return undefined;
}
if (metaName && titleName && metaName !== titleName) {
return undefined;
}
if (rawInputName && metaName && rawInputName !== metaName) {
return undefined;
}
if (rawInputName && titleName && rawInputName !== titleName) {
return undefined;
}
return metaName ?? titleName ?? rawInputName;
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
}
function extractPathFromToolTitle(

View File

@@ -1,4 +1,3 @@
import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js";
import { listAcpBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentAcpBinding } from "../config/types.js";
@@ -22,23 +21,12 @@ import {
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") {
if (normalized === "discord" || normalized === "telegram") {
return normalized;
}
return null;
}
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
const trimmed = conversationId.trim();
if (!trimmed || trimmed.includes(":")) {
return false;
}
if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
return false;
}
return true;
}
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
@@ -134,23 +122,14 @@ function resolveConfiguredBindingRecord(params: {
bindings: AgentAcpBinding[];
channel: ConfiguredAcpBindingChannel;
accountId: string;
selectConversation: (binding: AgentAcpBinding) => {
conversationId: string;
parentConversationId?: string;
matchPriority?: number;
} | null;
selectConversation: (
binding: AgentAcpBinding,
) => { conversationId: string; parentConversationId?: string } | null;
}): ResolvedConfiguredAcpBinding | null {
let wildcardMatch: {
binding: AgentAcpBinding;
conversationId: string;
parentConversationId?: string;
matchPriority: number;
} | null = null;
let exactMatch: {
binding: AgentAcpBinding;
conversationId: string;
parentConversationId?: string;
matchPriority: number;
} | null = null;
for (const binding of params.bindings) {
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
@@ -167,40 +146,23 @@ function resolveConfiguredBindingRecord(params: {
if (!conversation) {
continue;
}
const matchPriority = conversation.matchPriority ?? 0;
if (accountMatchPriority === 2) {
if (!exactMatch || matchPriority > exactMatch.matchPriority) {
exactMatch = {
binding,
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
matchPriority,
};
}
continue;
}
if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) {
wildcardMatch = {
binding,
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
matchPriority,
};
}
}
if (exactMatch) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
conversationId: exactMatch.conversationId,
parentConversationId: exactMatch.parentConversationId,
binding: exactMatch.binding,
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
if (accountMatchPriority === 2) {
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
if (!wildcardMatch) {
wildcardMatch = { binding, ...conversation };
}
}
if (!wildcardMatch) {
return null;
@@ -266,42 +228,6 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
}
continue;
}
if (channel === "feishu") {
const targetParsed = parseFeishuConversationId({
conversationId: targetConversationId,
});
if (
!targetParsed ||
(targetParsed.scope !== "group_topic" &&
targetParsed.scope !== "group_topic_sender" &&
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
) {
continue;
}
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "feishu",
accountId: parsedSessionKey.accountId,
conversationId: targetParsed.canonicalConversationId,
// Session-key recovery deliberately collapses sender-scoped topic bindings onto the
// canonical topic conversation id so `group_topic` and `group_topic_sender` reuse
// the same configured ACP session identity.
parentConversationId:
targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender"
? targetParsed.chatId
: undefined,
binding,
});
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
if (accountMatchPriority === 2) {
return spec;
}
if (!wildcardMatch) {
wildcardMatch = spec;
}
}
continue;
}
const parsedTopic = parseTelegramTopicConversation({
conversationId: targetConversationId,
});
@@ -408,63 +334,5 @@ export function resolveConfiguredAcpBindingRecord(params: {
});
}
if (channel === "feishu") {
const parsed = parseFeishuConversationId({
conversationId,
parentConversationId,
});
if (
!parsed ||
(parsed.scope !== "group_topic" &&
parsed.scope !== "group_topic_sender" &&
!isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
) {
return null;
}
return resolveConfiguredBindingRecord({
cfg: params.cfg,
bindings: listAcpBindings(params.cfg),
channel: "feishu",
accountId,
selectConversation: (binding) => {
const targetConversationId = resolveBindingConversationId(binding);
if (!targetConversationId) {
return null;
}
const targetParsed = parseFeishuConversationId({
conversationId: targetConversationId,
});
if (
!targetParsed ||
(targetParsed.scope !== "group_topic" &&
targetParsed.scope !== "group_topic_sender" &&
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
) {
return null;
}
const matchesCanonicalConversation =
targetParsed.canonicalConversationId === parsed.canonicalConversationId;
const matchesParentTopicForSenderScopedConversation =
parsed.scope === "group_topic_sender" &&
targetParsed.scope === "group_topic" &&
parsed.chatId === targetParsed.chatId &&
parsed.topicId === targetParsed.topicId;
if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
return null;
}
return {
conversationId: matchesParentTopicForSenderScopedConversation
? targetParsed.canonicalConversationId
: parsed.canonicalConversationId,
parentConversationId:
parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
? parsed.chatId
: undefined,
matchPriority: matchesCanonicalConversation ? 2 : 1,
};
},
});
}
return null;
}

View File

@@ -90,27 +90,6 @@ function createTelegramGroupBinding(params: {
} as ConfiguredBinding;
}
function createFeishuBinding(params: {
agentId: string;
conversationId: string;
accountId?: string;
acp?: Record<string, unknown>;
}): ConfiguredBinding {
return {
type: "acp",
agentId: params.agentId,
match: {
channel: "feishu",
accountId: params.accountId ?? defaultDiscordAccountId,
peer: {
kind: params.conversationId.includes(":topic:") ? "group" : "direct",
id: params.conversationId,
},
},
...(params.acp ? { acp: params.acp } : {}),
} as ConfiguredBinding;
}
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
return resolveConfiguredAcpBindingRecord({
cfg,
@@ -226,34 +205,6 @@ describe("resolveConfiguredAcpBindingRecord", () => {
expect(resolved?.spec.agentId).toBe("claude");
});
it("prefers sender-scoped Feishu bindings over topic inheritance", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "codex",
conversationId: "oc_group_chat:topic:om_topic_root",
accountId: "work",
}),
createFeishuBinding({
agentId: "claude",
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
accountId: "work",
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "work",
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
});
expect(resolved?.spec.conversationId).toBe(
"oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
);
expect(resolved?.spec.agentId).toBe("claude");
});
it("prefers exact account binding over wildcard for the same discord conversation", () => {
const cfg = createCfgWithBindings([
createDiscordBinding({
@@ -333,128 +284,6 @@ describe("resolveConfiguredAcpBindingRecord", () => {
expect(resolved).toBeNull();
});
it("resolves Feishu DM bindings using direct peer ids", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "codex",
conversationId: "ou_user_1",
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "ou_user_1",
});
expect(resolved?.spec.channel).toBe("feishu");
expect(resolved?.spec.conversationId).toBe("ou_user_1");
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
});
it("resolves Feishu DM bindings using user_id fallback peer ids", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "codex",
conversationId: "user_123",
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "user_123",
});
expect(resolved?.spec.channel).toBe("feishu");
expect(resolved?.spec.conversationId).toBe("user_123");
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
});
it("resolves Feishu topic bindings with parent chat ids", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "claude",
conversationId: "oc_group_chat:topic:om_topic_root",
acp: { backend: "acpx" },
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
});
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
expect(resolved?.spec.agentId).toBe("claude");
expect(resolved?.record.conversation.parentConversationId).toBe("oc_group_chat");
});
it("inherits configured Feishu topic bindings for sender-scoped topic conversations", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "claude",
conversationId: "oc_group_chat:topic:om_topic_root",
acp: { backend: "acpx" },
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
parentConversationId: "oc_group_chat",
});
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
expect(resolved?.spec.agentId).toBe("claude");
expect(resolved?.spec.backend).toBe("acpx");
expect(resolved?.record.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
});
it("rejects non-matching Feishu topic roots", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "claude",
conversationId: "oc_group_chat:topic:om_topic_root",
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_other_root",
parentConversationId: "oc_group_chat",
});
expect(resolved).toBeNull();
});
it("rejects Feishu non-topic group ACP bindings", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "claude",
conversationId: "oc_group_chat",
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat",
});
expect(resolved).toBeNull();
});
it("applies agent runtime ACP defaults for bound conversations", () => {
const cfg = createCfgWithBindings(
[
@@ -536,31 +365,6 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
expect(spec?.backend).toBe("exact");
});
it("maps a configured Feishu user_id DM binding session key back to its spec", () => {
const cfg = createCfgWithBindings([
createFeishuBinding({
agentId: "codex",
conversationId: "user_123",
acp: { backend: "acpx" },
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "user_123",
});
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
expect(spec?.channel).toBe("feishu");
expect(spec?.conversationId).toBe("user_123");
expect(spec?.agentId).toBe("codex");
expect(spec?.backend).toBe("acpx");
});
});
describe("buildConfiguredAcpSessionKey", () => {

View File

@@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
import { sanitizeAgentId } from "../routing/session-key.js";
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu";
export type ConfiguredAcpBindingChannel = "discord" | "telegram";
export type ConfiguredAcpBindingSpec = {
channel: ConfiguredAcpBindingChannel;

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { applyPatch } from "./apply-patch.js";
async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
@@ -147,25 +147,6 @@ describe("applyPatch", () => {
});
});
it("resolves delete targets before calling fs.rm", async () => {
await withTempDir(async (dir) => {
const target = path.join(dir, "delete-me.txt");
await fs.writeFile(target, "x\n", "utf8");
const rmSpy = vi.spyOn(fs, "rm");
try {
const patch = `*** Begin Patch
*** Delete File: delete-me.txt
*** End Patch`;
await applyPatch(patch, { cwd: dir });
expect(rmSpy).toHaveBeenCalledWith(target);
} finally {
rmSpy.mockRestore();
}
});
});
it("rejects symlink escape attempts by default", async () => {
// File symlinks require SeCreateSymbolicLinkPrivilege on Windows.
if (process.platform === "win32") {

View File

@@ -270,28 +270,8 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps {
encoding: "utf8",
});
},
remove: async (filePath) => {
if (workspaceOnly) {
await assertSandboxPath({
filePath,
cwd: options.cwd,
root: options.cwd,
allowFinalSymlinkForUnlink: true,
allowFinalHardlinkForUnlink: true,
});
}
await fs.rm(filePath);
},
mkdirp: async (dir) => {
if (workspaceOnly) {
await assertSandboxPath({
filePath: dir,
cwd: options.cwd,
root: options.cwd,
});
}
await fs.mkdir(dir, { recursive: true });
},
remove: (filePath) => fs.rm(filePath),
mkdirp: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}),
};
}

View File

@@ -90,20 +90,6 @@ describe("lookupContextTokens", () => {
}
});
it("skips eager warmup for logs commands that do not need model metadata at startup", async () => {
const loadConfigMock = vi.fn(() => ({ models: {} }));
mockContextModuleDeps(loadConfigMock);
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "logs", "--limit", "5"];
try {
await import("./context.js");
expect(loadConfigMock).not.toHaveBeenCalled();
} finally {
process.argv = argvSnapshot;
}
});
it("retries config loading after backoff when an initial load fails", async () => {
vi.useFakeTimers();
const loadConfigMock = vi

View File

@@ -108,24 +108,9 @@ function getCommandPathFromArgv(argv: string[]): string[] {
return tokens;
}
const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([
"backup",
"completion",
"config",
"directory",
"doctor",
"health",
"hooks",
"logs",
"plugins",
"secrets",
"update",
"webhooks",
]);
function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): boolean {
const [primary] = getCommandPathFromArgv(argv);
return primary ? SKIP_EAGER_WARMUP_PRIMARY_COMMANDS.has(primary) : false;
const [primary, secondary] = getCommandPathFromArgv(argv);
return primary === "config" && secondary === "validate";
}
function primeConfiguredContextWindows(): OpenClawConfig | undefined {

View File

@@ -28,10 +28,6 @@ function supportsUsageInStreaming(model: Model<Api>): boolean | undefined {
?.supportsUsageInStreaming;
}
function supportsStrictMode(model: Model<Api>): boolean | undefined {
return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode;
}
function createTemplateModel(provider: string, id: string): Model<Api> {
return {
id,
@@ -98,13 +94,6 @@ function expectSupportsUsageInStreamingForcedOff(overrides?: Partial<Model<Api>>
expect(supportsUsageInStreaming(normalized)).toBe(false);
}
function expectSupportsStrictModeForcedOff(overrides?: Partial<Model<Api>>): void {
const model = { ...baseModel(), ...overrides };
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model as Model<Api>);
expect(supportsStrictMode(normalized)).toBe(false);
}
function expectResolvedForwardCompat(
model: Model<Api> | undefined,
expected: { provider: string; id: string },
@@ -237,17 +226,6 @@ describe("normalizeModelCompat", () => {
});
});
it("forces supportsStrictMode off for z.ai models", () => {
expectSupportsStrictModeForcedOff();
});
it("forces supportsStrictMode off for custom openai-completions provider", () => {
expectSupportsStrictModeForcedOff({
provider: "custom-cpa",
baseUrl: "https://cpa.example.com/v1",
});
});
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
expectSupportsDeveloperRoleForcedOff({
provider: "qwen-proxy",
@@ -305,18 +283,6 @@ describe("normalizeModelCompat", () => {
const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
it("respects explicit supportsStrictMode true on non-native endpoints", () => {
const model = {
...baseModel(),
provider: "custom-cpa",
baseUrl: "https://proxy.example.com/v1",
compat: { supportsStrictMode: true },
};
const normalized = normalizeModelCompat(model);
expect(supportsStrictMode(normalized)).toBe(true);
});
it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
@@ -330,23 +296,16 @@ describe("normalizeModelCompat", () => {
expect(normalized).not.toBe(model);
expect(supportsDeveloperRole(model)).toBeUndefined();
expect(supportsUsageInStreaming(model)).toBeUndefined();
expect(supportsStrictMode(model)).toBeUndefined();
expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
it("does not override explicit compat false", () => {
const model = baseModel();
model.compat = {
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
};
model.compat = { supportsDeveloperRole: false, supportsUsageInStreaming: false };
const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
});

View File

@@ -54,10 +54,9 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
// The `developer` role and stream usage chunks are OpenAI-native behaviors.
// Many OpenAI-compatible backends reject `developer` and/or emit usage-only
// chunks that break strict parsers expecting choices[0]. Additionally, the
// `strict` boolean inside tools validation is rejected by several providers
// causing tool calls to be ignored. For non-native openai-completions endpoints,
// default these compat flags off unless explicitly opted in.
// chunks that break strict parsers expecting choices[0]. For non-native
// openai-completions endpoints, force both compat flags off — unless the
// user has explicitly opted in via their model config.
const compat = model.compat ?? undefined;
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so
// leave compat unchanged and let default native behavior apply.
@@ -65,14 +64,13 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
if (!needsForce) {
return model;
}
// Respect explicit user overrides: if the user has set a compat flag to
// true in their model definition, they know their endpoint supports it.
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
const targetStrictMode = compat?.supportsStrictMode ?? false;
if (
compat?.supportsDeveloperRole !== undefined &&
compat?.supportsUsageInStreaming !== undefined &&
compat?.supportsStrictMode !== undefined
) {
if (forcedDeveloperRole && forcedUsageStreaming) {
return model;
}
@@ -84,12 +82,7 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
...compat,
supportsDeveloperRole: forcedDeveloperRole || false,
supportsUsageInStreaming: forcedUsageStreaming || false,
supportsStrictMode: targetStrictMode,
}
: {
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
},
: { supportsDeveloperRole: false, supportsUsageInStreaming: false },
} as typeof model;
}

View File

@@ -1,23 +0,0 @@
// Keep model ID normalization dependency-free so config parsing and other
// startup-only paths do not pull in provider discovery or plugin loading.
export function normalizeGoogleModelId(id: string): string {
if (id === "gemini-3-pro") {
return "gemini-3-pro-preview";
}
if (id === "gemini-3-flash") {
return "gemini-3-flash-preview";
}
if (id === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
if (id === "gemini-3.1-flash-lite") {
return "gemini-3.1-flash-lite-preview";
}
// Preserve compatibility with earlier OpenClaw docs/config that pointed at a
// non-existent Gemini Flash preview ID. Google's current Flash text model is
// `gemini-3-flash-preview`.
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
return "gemini-3-flash-preview";
}
return id;
}

View File

@@ -14,8 +14,8 @@ import {
} from "./agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type { ModelCatalogEntry } from "./model-catalog.js";
import { normalizeGoogleModelId } from "./model-id-normalization.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
import { normalizeGoogleModelId } from "./models-config.providers.js";
const log = createSubsystemLogger("model-selection");

View File

@@ -2,9 +2,9 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { normalizeGoogleModelId } from "./model-id-normalization.js";
import {
normalizeAntigravityModelId,
normalizeGoogleModelId,
normalizeProviders,
type ProviderConfig,
} from "./models-config.providers.js";

View File

@@ -12,7 +12,6 @@ import {
buildCloudflareAiGatewayModelDefinition,
resolveCloudflareAiGatewayBaseUrl,
} from "./cloudflare-ai-gateway.js";
import { normalizeGoogleModelId } from "./model-id-normalization.js";
import {
buildHuggingfaceProvider,
buildKilocodeProviderWithDiscovery,
@@ -71,7 +70,6 @@ import {
} from "./model-auth-markers.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
export { normalizeGoogleModelId };
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@@ -225,6 +223,28 @@ function resolveApiKeyFromProfiles(params: {
return undefined;
}
export function normalizeGoogleModelId(id: string): string {
if (id === "gemini-3-pro") {
return "gemini-3-pro-preview";
}
if (id === "gemini-3-flash") {
return "gemini-3-flash-preview";
}
if (id === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
if (id === "gemini-3.1-flash-lite") {
return "gemini-3.1-flash-lite-preview";
}
// Preserve compatibility with earlier OpenClaw docs/config that pointed at a
// non-existent Gemini Flash preview ID. Google's current Flash text model is
// `gemini-3-flash-preview`.
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
return "gemini-3-flash-preview";
}
return id;
}
const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
export function normalizeAntigravityModelId(id: string): string {

View File

@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
compactWithSafetyTimeout,
EMBEDDED_COMPACTION_TIMEOUT_MS,
resolveCompactionTimeoutMs,
} from "./pi-embedded-runner/compaction-safety-timeout.js";
describe("compactWithSafetyTimeout", () => {
@@ -43,113 +42,4 @@ describe("compactWithSafetyTimeout", () => {
).rejects.toBe(error);
expect(vi.getTimerCount()).toBe(0);
});
it("calls onCancel when compaction times out", async () => {
vi.useFakeTimers();
const onCancel = vi.fn();
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
onCancel,
});
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
await vi.advanceTimersByTimeAsync(30);
await timeoutAssertion;
expect(onCancel).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("aborts early on external abort signal and calls onCancel once", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const onCancel = vi.fn();
const reason = new Error("request timed out");
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 100, {
abortSignal: controller.signal,
onCancel,
});
const abortAssertion = expect(compactPromise).rejects.toBe(reason);
controller.abort(reason);
await abortAssertion;
expect(onCancel).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("ignores onCancel errors and still rejects with the timeout", async () => {
vi.useFakeTimers();
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
onCancel: () => {
throw new Error("abortCompaction failed");
},
});
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
await vi.advanceTimersByTimeAsync(30);
await timeoutAssertion;
expect(vi.getTimerCount()).toBe(0);
});
});
describe("resolveCompactionTimeoutMs", () => {
it("returns default when config is undefined", () => {
expect(resolveCompactionTimeoutMs(undefined)).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default when compaction config is missing", () => {
expect(resolveCompactionTimeoutMs({ agents: { defaults: {} } })).toBe(
EMBEDDED_COMPACTION_TIMEOUT_MS,
);
});
it("returns default when timeoutSeconds is not set", () => {
expect(
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { mode: "safeguard" } } } }),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("converts timeoutSeconds to milliseconds", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: 1800 } } },
}),
).toBe(1_800_000);
});
it("floors fractional seconds", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: 120.7 } } },
}),
).toBe(120_000);
});
it("returns default for zero", () => {
expect(
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: 0 } } } }),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default for negative values", () => {
expect(
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: -5 } } } }),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default for NaN", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: NaN } } },
}),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default for Infinity", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: Infinity } } },
}),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
});

View File

@@ -2,7 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
expectOpenAIResponsesStrictSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager,
makeInMemorySessionManager,
@@ -248,24 +247,7 @@ describe("sanitizeSessionHistory", () => {
expect(result).toEqual(mockMessages);
});
it("sanitizes tool call ids for OpenAI-compatible responses providers", async () => {
setNonGoogleModelApi();
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "openai-responses",
provider: "custom",
sessionManager: mockSessionManager,
sessionId: TEST_SESSION_ID,
});
expectOpenAIResponsesStrictSanitizeCall(
mockedHelpers.sanitizeSessionMessagesImages,
mockMessages,
);
});
it("sanitizes tool call ids for openai-completions", async () => {
it("passes simple user-only history through for openai-completions", async () => {
setNonGoogleModelApi();
const result = await sanitizeSessionHistory({

View File

@@ -14,7 +14,6 @@ const {
resolveMemorySearchConfigMock,
resolveSessionAgentIdMock,
estimateTokensMock,
sessionAbortCompactionMock,
} = vi.hoisted(() => {
const contextEngineCompactMock = vi.fn(async () => ({
ok: true as boolean,
@@ -66,7 +65,6 @@ const {
})),
resolveSessionAgentIdMock: vi.fn(() => "main"),
estimateTokensMock: vi.fn((_message?: unknown) => 10),
sessionAbortCompactionMock: vi.fn(),
};
});
@@ -123,7 +121,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => {
session.messages.splice(1);
return await sessionCompactImpl();
}),
abortCompaction: sessionAbortCompactionMock,
dispose: vi.fn(),
};
return { session };
@@ -154,7 +151,6 @@ vi.mock("../models-config.js", () => ({
}));
vi.mock("../model-auth.js", () => ({
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
resolveModelAuthMode: vi.fn(() => "env"),
}));
@@ -424,7 +420,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
resolveSessionAgentIdMock.mockReturnValue("main");
estimateTokensMock.mockReset();
estimateTokensMock.mockReturnValue(10);
sessionAbortCompactionMock.mockReset();
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
});
@@ -777,24 +772,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
expect(result.ok).toBe(true);
});
it("aborts in-flight compaction when the caller abort signal fires", async () => {
const controller = new AbortController();
sessionCompactImpl.mockImplementationOnce(() => new Promise<never>(() => {}));
const resultPromise = compactEmbeddedPiSessionDirect(
directCompactionArgs({
abortSignal: controller.signal,
}),
);
controller.abort(new Error("request timed out"));
const result = await resultPromise;
expect(result.ok).toBe(false);
expect(result.reason).toContain("request timed out");
expect(sessionAbortCompactionMock).toHaveBeenCalledTimes(1);
});
});
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {

Some files were not shown because too many files have changed in this diff Show More