Compare commits

..

4 Commits

Author SHA1 Message Date
Josh Lehman
716fbdad5d docs: fix context engine review notes 2026-03-17 00:08:09 -07:00
Josh Lehman
4f158ef917 docs: address review feedback on context-engine page
- Rename 'Method' column to 'Member' with explicit Kind column since
  info is a property, not a callable method
- Document AssembleResult fields (estimatedTokens, systemPromptAddition)
  with types and optionality
- Add lifecycle timing notes for bootstrap, ingestBatch, and dispose
  so plugin authors know when each is invoked
2026-03-14 18:03:06 -07:00
Josh Lehman
8075e5e0b9 docs: add plugin installation steps to context engine page
Show the full workflow: install via openclaw plugins install,
enable in plugins.entries, then select in plugins.slots.contextEngine.
Uses lossless-claw as the concrete example.
2026-03-14 15:59:34 -07:00
Josh Lehman
24b19b8624 docs: add context engine documentation
Add dedicated docs page for the pluggable context engine system:
- Full lifecycle explanation (ingest, assemble, compact, afterTurn)
- Legacy engine behavior documentation
- Plugin engine authoring guide with code examples
- ContextEngine interface reference table
- ownsCompaction semantics
- Subagent lifecycle hooks (prepareSubagentSpawn, onSubagentEnded)
- systemPromptAddition mechanism
- Relationship to compaction, memory plugins, and session pruning
- Configuration reference and tips

Also:
- Add context-engine to docs nav (Agents > Fundamentals, after Context)
- Add /context-engine redirect
- Cross-link from context.md and compaction.md
2026-03-14 15:59:33 -07:00
339 changed files with 4032 additions and 22196 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

@@ -12314,14 +12314,14 @@
"filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false,
"line_number": 657
"line_number": 653
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false,
"line_number": 690
"line_number": 686
}
],
"src/config/schema.irc.ts": [
@@ -12360,14 +12360,14 @@
"filename": "src/config/schema.labels.ts",
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
"is_verified": false,
"line_number": 219
"line_number": 217
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false,
"line_number": 328
"line_number": 326
}
],
"src/config/slack-http-config.test.ts": [

View File

@@ -6,63 +6,24 @@ 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.
- Browser/existing-session: add headless Chrome DevTools MCP support for Linux, Docker, and VPS setups, including explicit browser URL and WebSocket endpoint attach modes for `existing-session`. Thanks @vincentkoc.
### 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.
- 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.
### 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.
- 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.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
- 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.
- 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.
- 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.
## 2026.3.13
@@ -136,10 +97,7 @@ Docs: https://docs.openclaw.ai
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
## 2026.3.12

View File

@@ -134,7 +134,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git lsof openssl
procps hostname curl git openssl
RUN chown node:node /app

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

@@ -51,7 +51,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.mobileCardSurface
private enum class ConnectInputMode {
SetupCode,
@@ -92,28 +91,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")
}
},
@@ -153,7 +144,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileCardSurface,
color = Color.White,
border = BorderStroke(1.dp, mobileBorder),
) {
Column {
@@ -214,7 +205,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileCardSurface,
containerColor = Color.White,
contentColor = mobileDanger,
),
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
@@ -307,7 +298,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileCardSurface,
color = Color.White,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(
@@ -489,7 +480,7 @@ private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
containerColor = if (active) mobileAccent else mobileSurface,
contentColor = if (active) Color.White else mobileText,
),
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
}
@@ -518,10 +509,10 @@ private fun CommandBlock(command: String) {
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, mobileCodeBorder),
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.width(3.dp).height(42.dp).background(mobileCodeAccent))
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
Text(
text = command,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),

View File

@@ -1,7 +1,5 @@
package ai.openclaw.app.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
@@ -11,147 +9,32 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ai.openclaw.app.R
// ---------------------------------------------------------------------------
// MobileColors semantic color tokens with light + dark variants
// ---------------------------------------------------------------------------
internal data class MobileColors(
val surface: Color,
val surfaceStrong: Color,
val cardSurface: Color,
val border: Color,
val borderStrong: Color,
val text: Color,
val textSecondary: Color,
val textTertiary: Color,
val accent: Color,
val accentSoft: Color,
val accentBorderStrong: Color,
val success: Color,
val successSoft: Color,
val warning: Color,
val warningSoft: Color,
val danger: Color,
val dangerSoft: Color,
val codeBg: Color,
val codeText: Color,
val codeBorder: Color,
val codeAccent: Color,
val chipBorderConnected: Color,
val chipBorderConnecting: Color,
val chipBorderWarning: Color,
val chipBorderError: Color,
)
internal fun lightMobileColors() =
MobileColors(
surface = Color(0xFFF6F7FA),
surfaceStrong = Color(0xFFECEEF3),
cardSurface = Color(0xFFFFFFFF),
border = Color(0xFFE5E7EC),
borderStrong = Color(0xFFD6DAE2),
text = Color(0xFF17181C),
textSecondary = Color(0xFF5D6472),
textTertiary = Color(0xFF99A0AE),
accent = Color(0xFF1D5DD8),
accentSoft = Color(0xFFECF3FF),
accentBorderStrong = Color(0xFF184DAF),
success = Color(0xFF2F8C5A),
successSoft = Color(0xFFEEF9F3),
warning = Color(0xFFC8841A),
warningSoft = Color(0xFFFFF8EC),
danger = Color(0xFFD04B4B),
dangerSoft = Color(0xFFFFF2F2),
codeBg = Color(0xFF15171B),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFFCFEBD8),
chipBorderConnecting = Color(0xFFD5E2FA),
chipBorderWarning = Color(0xFFEED8B8),
chipBorderError = Color(0xFFF3C8C8),
internal val mobileBackgroundGradient =
Brush.verticalGradient(
listOf(
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
),
)
internal fun darkMobileColors() =
MobileColors(
surface = Color(0xFF1A1C20),
surfaceStrong = Color(0xFF24262B),
cardSurface = Color(0xFF1E2024),
border = Color(0xFF2E3038),
borderStrong = Color(0xFF3A3D46),
text = Color(0xFFE4E5EA),
textSecondary = Color(0xFFA0A6B4),
textTertiary = Color(0xFF6B7280),
accent = Color(0xFF6EA8FF),
accentSoft = Color(0xFF1A2A44),
accentBorderStrong = Color(0xFF5B93E8),
success = Color(0xFF5FBB85),
successSoft = Color(0xFF152E22),
warning = Color(0xFFE8A844),
warningSoft = Color(0xFF2E2212),
danger = Color(0xFFE87070),
dangerSoft = Color(0xFF2E1616),
codeBg = Color(0xFF111317),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFF1E4A30),
chipBorderConnecting = Color(0xFF1E3358),
chipBorderWarning = Color(0xFF3E3018),
chipBorderError = Color(0xFF3E1E1E),
)
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
internal object MobileColorsAccessor {
val current: MobileColors
@Composable get() = LocalMobileColors.current
}
// ---------------------------------------------------------------------------
// Backward-compatible top-level accessors (composable getters)
// ---------------------------------------------------------------------------
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
// without converting every file at once. Each resolves to the themed value.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
// Background gradient light fades white→gray, dark fades near-black→dark-gray
internal val mobileBackgroundGradient: Brush
@Composable get() {
val colors = LocalMobileColors.current
return Brush.verticalGradient(
listOf(
colors.surface,
colors.surfaceStrong,
colors.surfaceStrong,
),
)
}
// ---------------------------------------------------------------------------
// Typography tokens (theme-independent)
// ---------------------------------------------------------------------------
internal val mobileSurface = Color(0xFFF6F7FA)
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
internal val mobileBorder = Color(0xFFE5E7EC)
internal val mobileBorderStrong = Color(0xFFD6DAE2)
internal val mobileText = Color(0xFF17181C)
internal val mobileTextSecondary = Color(0xFF5D6472)
internal val mobileTextTertiary = Color(0xFF99A0AE)
internal val mobileAccent = Color(0xFF1D5DD8)
internal val mobileAccentSoft = Color(0xFFECF3FF)
internal val mobileSuccess = Color(0xFF2F8C5A)
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
internal val mobileWarning = Color(0xFFC8841A)
internal val mobileWarningSoft = Color(0xFFFFF8EC)
internal val mobileDanger = Color(0xFFD04B4B)
internal val mobileDangerSoft = Color(0xFFFFF2F2)
internal val mobileCodeBg = Color(0xFF15171B)
internal val mobileCodeText = Color(0xFFE8EAEE)
internal val mobileFontFamily =
FontFamily(
@@ -161,15 +44,6 @@ internal val mobileFontFamily =
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
internal val mobileDisplay =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
)
internal val mobileTitle1 =
TextStyle(
fontFamily = mobileFontFamily,

View File

@@ -81,6 +81,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -93,6 +94,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.node.DeviceNotificationListenerService
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
@@ -121,87 +123,101 @@ private enum class PermissionToggle {
Calendar,
Motion,
Sms,
CallLog,
}
private enum class SpecialAccessToggle {
NotificationListener,
}
private val onboardingBackgroundGradient: Brush
@Composable get() = mobileBackgroundGradient
private val onboardingBackgroundGradient =
listOf(
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
)
private val onboardingSurface = Color(0xFFF6F7FA)
private val onboardingBorder = Color(0xFFE5E7EC)
private val onboardingBorderStrong = Color(0xFFD6DAE2)
private val onboardingText = Color(0xFF17181C)
private val onboardingTextSecondary = Color(0xFF4D5563)
private val onboardingTextTertiary = Color(0xFF8A92A2)
private val onboardingAccent = Color(0xFF1D5DD8)
private val onboardingAccentSoft = Color(0xFFECF3FF)
private val onboardingSuccess = Color(0xFF2F8C5A)
private val onboardingWarning = Color(0xFFC8841A)
private val onboardingCommandBg = Color(0xFF15171B)
private val onboardingCommandBorder = Color(0xFF2B2E35)
private val onboardingCommandAccent = Color(0xFF3FC97A)
private val onboardingCommandText = Color(0xFFE8EAEE)
private val onboardingSurface: Color
@Composable get() = mobileCardSurface
private val onboardingFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
private val onboardingBorder: Color
@Composable get() = mobileBorder
private val onboardingDisplayStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
)
private val onboardingBorderStrong: Color
@Composable get() = mobileBorderStrong
private val onboardingTitle1Style =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.5).sp,
)
private val onboardingText: Color
@Composable get() = mobileText
private val onboardingHeadlineStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp,
)
private val onboardingTextSecondary: Color
@Composable get() = mobileTextSecondary
private val onboardingBodyStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)
private val onboardingTextTertiary: Color
@Composable get() = mobileTextTertiary
private val onboardingCalloutStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
)
private val onboardingAccent: Color
@Composable get() = mobileAccent
private val onboardingCaption1Style =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
)
private val onboardingAccentSoft: Color
@Composable get() = mobileAccentSoft
private val onboardingAccentBorderStrong: Color
@Composable get() = mobileAccentBorderStrong
private val onboardingSuccess: Color
@Composable get() = mobileSuccess
private val onboardingSuccessSoft: Color
@Composable get() = mobileSuccessSoft
private val onboardingWarning: Color
@Composable get() = mobileWarning
private val onboardingWarningSoft: Color
@Composable get() = mobileWarningSoft
private val onboardingCommandBg: Color
@Composable get() = mobileCodeBg
private val onboardingCommandBorder: Color
@Composable get() = mobileCodeBorder
private val onboardingCommandAccent: Color
@Composable get() = mobileCodeAccent
private val onboardingCommandText: Color
@Composable get() = mobileCodeText
private val onboardingDisplayStyle: TextStyle
get() = mobileDisplay
private val onboardingTitle1Style: TextStyle
get() = mobileTitle1
private val onboardingHeadlineStyle: TextStyle
get() = mobileHeadline
private val onboardingBodyStyle: TextStyle
get() = mobileBody
private val onboardingCalloutStyle: TextStyle
get() = mobileCallout
private val onboardingCaption1Style: TextStyle
get() = mobileCaption1
private val onboardingCaption2Style: TextStyle
get() = mobileCaption2
private val onboardingCaption2Style =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.4.sp,
)
@Composable
fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
@@ -289,10 +305,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 +321,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 +348,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 +369,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableCalendar,
enableMotion,
enableSms,
enableCallLog,
smsAvailable,
motionAvailable,
) {
@@ -375,7 +384,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 +472,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")
}
},
@@ -496,7 +495,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
modifier =
modifier
.fillMaxSize()
.background(onboardingBackgroundGradient),
.background(Brush.verticalGradient(onboardingBackgroundGradient)),
) {
Column(
modifier =
@@ -604,7 +603,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms,
smsAvailable = smsAvailable,
enableCallLog = enableCallLog,
context = context,
onDiscoveryChange = { checked ->
requestPermissionToggle(
@@ -702,13 +700,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
}
},
onCallLogChange = { checked ->
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
},
)
OnboardingStep.FinalCheck ->
FinalStep(
@@ -764,7 +755,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onClick = { step = OnboardingStep.Gateway },
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = onboardingPrimaryButtonColors(),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@@ -810,7 +807,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = onboardingPrimaryButtonColors(),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@@ -824,7 +827,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = onboardingPrimaryButtonColors(),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@@ -835,7 +844,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onClick = { viewModel.setOnboardingCompleted(true) },
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = onboardingPrimaryButtonColors(),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) {
Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@@ -868,7 +883,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = onboardingPrimaryButtonColors(),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) {
Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@@ -880,36 +901,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
@Composable
private fun onboardingPrimaryButtonColors() =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
@Composable
private fun onboardingTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
)
@Composable
private fun onboardingSwitchColors() =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
)
@Composable
private fun StepRail(current: OnboardingStep) {
val steps = OnboardingStep.entries
@@ -1014,7 +1005,11 @@ private fun GatewayStep(
onClick = onScanQrClick,
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = onboardingPrimaryButtonColors(),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
),
) {
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@@ -1064,7 +1059,15 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
onboardingTextFieldColors(),
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
if (!resolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = resolvedEndpoint)
@@ -1094,7 +1097,15 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
onboardingTextFieldColors(),
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
@@ -1108,7 +1119,15 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
onboardingTextFieldColors(),
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Row(
@@ -1124,7 +1143,12 @@ private fun GatewayStep(
checked = manualTls,
onCheckedChange = onManualTlsChange,
colors =
onboardingSwitchColors(),
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
)
}
@@ -1139,7 +1163,15 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
onboardingTextFieldColors(),
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
@@ -1153,7 +1185,15 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
onboardingTextFieldColors(),
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
if (!manualResolvedEndpoint.isNullOrBlank()) {
@@ -1221,7 +1261,7 @@ private fun GatewayModeChip(
containerColor = if (active) onboardingAccent else onboardingSurface,
contentColor = if (active) Color.White else onboardingText,
),
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) onboardingAccentBorderStrong else onboardingBorderStrong),
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong),
) {
Text(
text = label,
@@ -1299,7 +1339,6 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean,
enableSms: Boolean,
smsAvailable: Boolean,
enableCallLog: Boolean,
context: Context,
onDiscoveryChange: (Boolean) -> Unit,
onLocationChange: (Boolean) -> Unit,
@@ -1312,7 +1351,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 +1481,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)
}
}
@@ -1495,7 +1524,13 @@ private fun PermissionToggleRow(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors = onboardingSwitchColors(),
colors =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
)
}
}
@@ -1570,7 +1605,7 @@ private fun FinalStep(
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingSuccessSoft,
color = Color(0xFFEEF9F3),
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
) {
Row(
@@ -1606,7 +1641,7 @@ private fun FinalStep(
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingWarningSoft,
color = Color(0xFFFFF8EC),
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
) {
Column(

View File

@@ -5,7 +5,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@@ -14,11 +13,8 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
@Composable

View File

@@ -159,28 +159,28 @@ private fun TopStatusBar(
mobileSuccessSoft,
mobileSuccess,
mobileSuccess,
LocalMobileColors.current.chipBorderConnected,
Color(0xFFCFEBD8),
)
StatusVisual.Connecting ->
listOf(
mobileAccentSoft,
mobileAccent,
mobileAccent,
LocalMobileColors.current.chipBorderConnecting,
Color(0xFFD5E2FA),
)
StatusVisual.Warning ->
listOf(
mobileWarningSoft,
mobileWarning,
mobileWarning,
LocalMobileColors.current.chipBorderWarning,
Color(0xFFEED8B8),
)
StatusVisual.Error ->
listOf(
mobileDangerSoft,
mobileDanger,
mobileDanger,
LocalMobileColors.current.chipBorderError,
Color(0xFFF3C8C8),
)
StatusVisual.Offline ->
listOf(
@@ -249,7 +249,7 @@ private fun BottomTabBar(
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = mobileCardSurface.copy(alpha = 0.97f),
color = Color.White.copy(alpha = 0.97f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 6.dp,
@@ -270,7 +270,7 @@ private fun BottomTabBar(
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
shape = RoundedCornerShape(16.dp),
color = if (active) mobileAccentSoft else Color.Transparent,
border = if (active) BorderStroke(1.dp, LocalMobileColors.current.chipBorderConnecting) else null,
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
shadowElevation = 0.dp,
) {
Column(

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(
@@ -776,12 +736,11 @@ private fun settingsTextFieldColors() =
cursorColor = mobileAccent,
)
@Composable
private fun Modifier.settingsRowModifier() =
this
.fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(mobileCardSurface, RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
@Composable
private fun settingsPrimaryButtonColors() =
@@ -822,7 +781,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 +791,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

@@ -363,7 +363,7 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
Surface(
modifier = Modifier.fillMaxWidth(0.90f),
shape = RoundedCornerShape(12.dp),
color = if (isUser) mobileAccentSoft else mobileCardSurface,
color = if (isUser) mobileAccentSoft else Color.White,
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
) {
Column(
@@ -391,7 +391,7 @@ private fun VoiceThinkingBubble() {
Surface(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(12.dp),
color = mobileCardSurface,
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(

View File

@@ -46,13 +46,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileAccentSoft
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileSurface
import ai.openclaw.app.ui.mobileText
@@ -112,7 +110,7 @@ fun ChatComposer(
Surface(
onClick = { showThinkingMenu = true },
shape = RoundedCornerShape(14.dp),
color = mobileCardSurface,
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
@@ -128,15 +126,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 }
@@ -187,7 +177,7 @@ fun ChatComposer(
disabledContainerColor = mobileBorderStrong,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, if (canSend) mobileAccentBorderStrong else mobileBorderStrong),
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
) {
if (sendBusy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
@@ -221,9 +211,9 @@ private fun SecondaryActionButton(
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileCardSurface,
containerColor = Color.White,
contentColor = mobileTextSecondary,
disabledContainerColor = mobileCardSurface,
disabledContainerColor = Color.White,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, mobileBorderStrong),
@@ -313,7 +303,7 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
Surface(
onClick = onRemove,
shape = RoundedCornerShape(999.dp),
color = mobileCardSurface,
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Text(

View File

@@ -94,7 +94,7 @@ private val markdownParser: Parser by lazy {
@Composable
fun ChatMarkdown(text: String, textColor: Color) {
val document = remember(text) { markdownParser.parse(text) as Document }
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RenderMarkdownBlocks(
@@ -124,7 +124,7 @@ private fun RenderMarkdownBlocks(
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
Text(
text = headingText,
style = headingStyle(current.level, inlineStyles.baseCallout),
style = headingStyle(current.level),
color = textColor,
)
}
@@ -231,7 +231,7 @@ private fun RenderParagraph(
Text(
text = annotated,
style = inlineStyles.baseCallout,
style = mobileCallout,
color = textColor,
)
}
@@ -315,7 +315,7 @@ private fun RenderListItem(
) {
Text(
text = marker,
style = inlineStyles.baseCallout.copy(fontWeight = FontWeight.SemiBold),
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = textColor,
modifier = Modifier.width(24.dp),
)
@@ -360,7 +360,7 @@ private fun RenderTableBlock(
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
Text(
text = cell,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
color = textColor,
modifier = Modifier
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
@@ -417,7 +417,6 @@ private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): Annot
node = start,
inlineCodeBg = inlineStyles.inlineCodeBg,
inlineCodeColor = inlineStyles.inlineCodeColor,
linkColor = inlineStyles.linkColor,
)
}
}
@@ -426,7 +425,6 @@ private fun AnnotatedString.Builder.appendInlineNode(
node: Node?,
inlineCodeBg: Color,
inlineCodeColor: Color,
linkColor: Color,
) {
var current = node
while (current != null) {
@@ -447,27 +445,27 @@ private fun AnnotatedString.Builder.appendInlineNode(
}
is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is Strikethrough -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is Link -> {
withStyle(
SpanStyle(
color = linkColor,
color = mobileAccent,
textDecoration = TextDecoration.Underline,
),
) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is MarkdownImage -> {
@@ -484,7 +482,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
}
}
else -> {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
current = current.next
@@ -521,21 +519,19 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
private fun headingStyle(level: Int, baseCallout: TextStyle): TextStyle {
private fun headingStyle(level: Int): TextStyle {
return when (level.coerceIn(1, 6)) {
1 -> baseCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> baseCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> baseCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> baseCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> baseCallout.copy(fontWeight = FontWeight.SemiBold)
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
}
}
private data class InlineStyles(
val inlineCodeBg: Color,
val inlineCodeColor: Color,
val linkColor: Color,
val baseCallout: TextStyle,
)
private data class TableRenderRow(

View File

@@ -19,7 +19,6 @@ import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
@@ -86,7 +85,7 @@ private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileCardSurface.copy(alpha = 0.9f),
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
) {
androidx.compose.foundation.layout.Column(

View File

@@ -36,9 +36,7 @@ import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCodeBg
import ai.openclaw.app.ui.mobileCodeBorder
import ai.openclaw.app.ui.mobileCodeText
import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileText
@@ -196,7 +194,6 @@ fun ChatStreamingAssistantBubble(text: String) {
}
}
@Composable
private fun bubbleStyle(role: String): ChatBubbleStyle {
return when (role) {
"user" ->
@@ -218,7 +215,7 @@ private fun bubbleStyle(role: String): ChatBubbleStyle {
else ->
ChatBubbleStyle(
alignEnd = false,
containerColor = mobileCardSurface,
containerColor = Color.White,
borderColor = mobileBorderStrong,
roleColor = mobileTextSecondary,
)
@@ -242,7 +239,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = mobileCardSurface,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
) {
Image(
@@ -280,7 +277,7 @@ fun ChatCodeBlock(code: String, language: String?) {
Surface(
shape = RoundedCornerShape(8.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, mobileCodeBorder),
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {

View File

@@ -36,15 +36,12 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileDanger
import ai.openclaw.app.ui.mobileDangerSoft
import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
import java.io.ByteArrayOutputStream
@@ -171,8 +168,8 @@ private fun ChatThreadSelector(
Surface(
onClick = { onSelectSession(entry.key) },
shape = RoundedCornerShape(14.dp),
color = if (active) mobileAccent else mobileCardSurface,
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
color = if (active) mobileAccent else Color.White,
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
@@ -193,7 +190,7 @@ private fun ChatThreadSelector(
private fun ChatErrorRail(errorText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = mobileDangerSoft,
color = androidx.compose.ui.graphics.Color.White,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) {

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClawNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

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":4731}
{"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}
@@ -138,7 +137,6 @@
{"recordType":"path","path":"agents.defaults.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.isolatedSession","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -342,7 +340,6 @@
{"recordType":"path","path":"agents.list.*.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.isolatedSession","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -913,8 +910,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 +1163,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 +1278,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 +1340,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 +1375,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 +1399,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 +1435,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 +1502,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 +1563,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 +1967,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 +2212,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 +2280,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 +2384,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 +2507,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 +2686,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 +2860,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 +3030,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 +3093,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 +3328,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 +3582,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

@@ -97,6 +97,17 @@ compaction and can run alongside it.
See [OpenAI provider](/providers/openai) for model params and overrides.
## Custom context engines
Compaction behavior is owned by the active
[context engine](/concepts/context-engine). The legacy engine uses the built-in
summarization described above. Plugin engines (selected via
`plugins.slots.contextEngine`) can implement any compaction strategy — DAG
summaries, vector retrieval, incremental condensation, etc.
When a plugin engine sets `ownsCompaction: true`, OpenClaw delegates all
compaction decisions to the engine and does not run built-in auto-compaction.
## Tips
- Use `/compact` when sessions feel stale or context is bloated.

View File

@@ -0,0 +1,250 @@
---
summary: "Context engine: pluggable context assembly, compaction, and subagent lifecycle"
read_when:
- You want to understand how OpenClaw assembles model context
- You are switching between the legacy engine and a plugin engine
- You are building a context engine plugin
title: "Context Engine"
---
# Context Engine
A **context engine** controls how OpenClaw builds model context for each run.
It decides which messages to include, how to summarize older history, and how
to manage context across subagent boundaries.
OpenClaw ships with a built-in `legacy` engine. Plugins can register
alternative engines that replace the entire context pipeline.
## Quick start
Check which engine is active:
```bash
openclaw doctor
# or inspect config directly:
cat ~/.openclaw/openclaw.json | jq '.plugins.slots.contextEngine'
```
### Installing a context engine plugin
Context engine plugins are installed like any other OpenClaw plugin. Install
first, then select the engine in the slot:
```bash
# Install from npm
openclaw plugins install @martian-engineering/lossless-claw
# Or install from a local path (for development)
openclaw plugins install -l ./my-context-engine
```
Then enable the plugin and select it as the active engine in your config:
```json5
// openclaw.json
{
plugins: {
slots: {
contextEngine: "lossless-claw", // must match the plugin's registered engine id
},
entries: {
"lossless-claw": {
enabled: true,
// Plugin-specific config goes here (see the plugin's docs)
},
},
},
}
```
Restart the gateway after installing and configuring.
To switch back to the built-in engine, set `contextEngine` to `"legacy"` (or
remove the key entirely — `"legacy"` is the default).
## How it works
Every time OpenClaw runs a model prompt, the context engine participates at
four lifecycle points:
1. **Ingest** — called when a new message is added to the session. The engine
can store or index the message in its own data store.
2. **Assemble** — called before each model run. The engine returns an ordered
set of messages (and an optional `systemPromptAddition`) that fit within
the token budget.
3. **Compact** — called when the context window is full, or when the user runs
`/compact`. The engine summarizes older history to free space.
4. **After turn** — called after a run completes. The engine can persist state,
trigger background compaction, or update indexes.
### Subagent lifecycle (optional)
OpenClaw currently calls one subagent lifecycle hook:
- **onSubagentEnded** — clean up when a subagent session completes or is swept.
The `prepareSubagentSpawn` hook is part of the interface for future use, but
the runtime does not invoke it yet.
### System prompt addition
The `assemble` method can return a `systemPromptAddition` string. OpenClaw
prepends this to the system prompt for the run. This lets engines inject
dynamic recall guidance, retrieval instructions, or context-aware hints
without requiring static workspace files.
## The legacy engine
The built-in `legacy` engine preserves OpenClaw's original behavior:
- **Ingest**: no-op (the session manager handles message persistence directly).
- **Assemble**: pass-through (the existing sanitize → validate → limit pipeline
in the runtime handles context assembly).
- **Compact**: delegates to the built-in summarization compaction, which creates
a single summary of older messages and keeps recent messages intact.
- **After turn**: no-op.
The legacy engine does not register tools or provide a `systemPromptAddition`.
When no `plugins.slots.contextEngine` is set (or it's set to `"legacy"`), this
engine is used automatically.
## Plugin engines
A plugin can register a context engine using the plugin API:
```ts
export default function register(api) {
api.registerContextEngine("my-engine", () => ({
info: {
id: "my-engine",
name: "My Context Engine",
ownsCompaction: true,
},
async ingest({ sessionId, message, isHeartbeat }) {
// Store the message in your data store
return { ingested: true };
},
async assemble({ sessionId, messages, tokenBudget }) {
// Return messages that fit the budget
return {
messages: buildContext(messages, tokenBudget),
estimatedTokens: countTokens(messages),
systemPromptAddition: "Use lcm_grep to search history...",
};
},
async compact({ sessionId, force }) {
// Summarize older context
return { ok: true, compacted: true };
},
}));
}
```
Then enable it in config:
```json5
{
plugins: {
slots: {
contextEngine: "my-engine",
},
entries: {
"my-engine": {
enabled: true,
},
},
},
}
```
### The ContextEngine interface
Required members:
| Member | Kind | Purpose |
| ------------------ | -------- | -------------------------------------------------------- |
| `info` | Property | Engine id, name, version, and whether it owns compaction |
| `ingest(params)` | Method | Store a single message |
| `assemble(params)` | Method | Build context for a model run (returns `AssembleResult`) |
| `compact(params)` | Method | Summarize/reduce context |
`assemble` returns an `AssembleResult` with:
- `messages` — the ordered messages to send to the model.
- `estimatedTokens` (required, `number`) — the engine's estimate of total
tokens in the assembled context. OpenClaw uses this for compaction threshold
decisions and diagnostic reporting.
- `systemPromptAddition` (optional, `string`) — prepended to the system prompt.
Optional members:
| Member | Kind | Purpose |
| ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------- |
| `bootstrap(params)` | Method | Initialize engine state for a session. Called once when the engine first sees a session (e.g., import history). |
| `ingestBatch(params)` | Method | Ingest a completed turn as a batch. Called after a run completes, with all messages from that turn at once. |
| `afterTurn(params)` | Method | Post-run lifecycle work (persist state, trigger background compaction). |
| `prepareSubagentSpawn(params)` | Method | Set up shared state for a child session. |
| `onSubagentEnded(params)` | Method | Clean up after a subagent ends. |
| `dispose()` | Method | Release resources. Called during gateway shutdown or plugin reload — not per-session. |
### ownsCompaction
When `info.ownsCompaction` is `true`, the engine manages its own compaction
lifecycle. OpenClaw will not trigger the built-in auto-compaction; instead it
delegates entirely to the engine's `compact()` method. The engine may also
run compaction proactively in `afterTurn()`.
When `false` or unset, OpenClaw's built-in auto-compaction logic runs
alongside the engine.
## Configuration reference
```json5
{
plugins: {
slots: {
// Select the active context engine. Default: "legacy".
// Set to a plugin id to use a plugin engine.
contextEngine: "legacy",
},
},
}
```
The slot is exclusive at run time — only one registered context engine is
resolved for a given run or compaction operation. Other enabled
`kind: "context-engine"` plugins can still load and run their registration
code; `plugins.slots.contextEngine` only selects which registered engine id
OpenClaw resolves when it needs a context engine.
## Relationship to compaction and memory
- **Compaction** is one responsibility of the context engine. The legacy engine
delegates to OpenClaw's built-in summarization. Plugin engines can implement
any compaction strategy (DAG summaries, vector retrieval, etc.).
- **Memory plugins** (`plugins.slots.memory`) are separate from context engines.
Memory plugins provide search/retrieval; context engines control what the
model sees. They can work together — a context engine might use memory
plugin data during assembly.
- **Session pruning** (trimming old tool results in-memory) still runs
regardless of which context engine is active.
## Tips
- Use `openclaw doctor` to verify your engine is loading correctly.
- If switching engines, existing sessions continue with their current history.
The new engine takes over for future runs.
- Engine errors are logged and surfaced in diagnostics. If a plugin engine
fails to register or the selected engine id cannot be resolved, OpenClaw
does not fall back automatically; runs fail until you fix the plugin or
switch `plugins.slots.contextEngine` back to `"legacy"`.
- For development, use `openclaw plugins install -l ./my-engine` to link a
local plugin directory without copying.
See also: [Compaction](/concepts/compaction), [Context](/concepts/context),
[Plugins](/tools/plugin), [Plugin manifest](/plugins/manifest).

View File

@@ -157,7 +157,8 @@ By default, OpenClaw uses the built-in `legacy` context engine for assembly and
compaction. If you install a plugin that provides `kind: "context-engine"` and
select it with `plugins.slots.contextEngine`, OpenClaw delegates context
assembly, `/compact`, and related subagent context lifecycle hooks to that
engine instead.
engine instead. See [Context Engine](/concepts/context-engine) for the full
pluggable interface, lifecycle hooks, and configuration.
## What `/context` actually reports

View File

@@ -186,15 +186,20 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
Kimi K2 model IDs:
[//]: # "moonshot-kimi-k2-model-refs:start"
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
<!-- markdownlint-enable MD037 -->
- `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo`
[//]: # "moonshot-kimi-k2-model-refs:end"
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-model-refs:end _/ && null}
<!-- markdownlint-enable MD037 -->
```json5
{

View File

@@ -59,6 +59,10 @@
"source": "/compaction",
"destination": "/concepts/compaction"
},
{
"source": "/context-engine",
"destination": "/concepts/context-engine"
},
{
"source": "/cron",
"destination": "/cron-jobs"
@@ -952,6 +956,7 @@
"concepts/agent-loop",
"concepts/system-prompt",
"concepts/context",
"concepts/context-engine",
"concepts/agent-workspace",
"concepts/oauth"
]
@@ -1242,6 +1247,7 @@
"group": "Security",
"pages": [
"security/formal-verification",
"security/README",
"security/THREAT-MODEL-ATLAS",
"security/CONTRIBUTING-THREAT-MODEL"
]
@@ -1597,6 +1603,7 @@
"zh-CN/tools/apply-patch",
"zh-CN/brave-search",
"zh-CN/perplexity",
"zh-CN/tools/diffs",
"zh-CN/tools/elevated",
"zh-CN/tools/exec",
"zh-CN/tools/exec-approvals",

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
@@ -975,7 +975,6 @@ Periodic heartbeat runs.
model: "openai/gpt-5.2-mini",
includeReasoning: false,
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
session: "main",
to: "+15555550123",
directPolicy: "allow", // allow (default) | block
@@ -993,7 +992,6 @@ Periodic heartbeat runs.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
- Heartbeats run full agent turns — shorter intervals burn more tokens.
@@ -1005,7 +1003,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 +1021,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 +2368,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 +2485,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

@@ -22,8 +22,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
7. Optional: restrict heartbeats to active hours (local time).
6. Optional: restrict heartbeats to active hours (local time).
Example config:
@@ -36,7 +35,6 @@ Example config:
target: "last", // explicit delivery to last contact (default is "none")
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
isolatedSession: true, // optional: fresh session each run (no conversation history)
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too
},
@@ -93,7 +91,6 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
model: "anthropic/claude-opus-4-6",
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
to: "+15551234567", // optional channel-specific override
accountId: "ops-bot", // optional multi-account channel id
@@ -215,7 +212,6 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `model`: optional model override for heartbeat runs (`provider/model`).
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
- `session`: optional session key for heartbeat runs.
- `main` (default): agent main session.
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
@@ -384,10 +380,6 @@ off in group chats.
## Cost awareness
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
- Keep `HEARTBEAT.md` small.
- Use `target: "none"` if you only want internal state updates.
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep
`HEARTBEAT.md` small and consider a cheaper `model` or `target: "none"` if you
only want internal state updates.

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

@@ -16,7 +16,7 @@ If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplex
## Getting a Perplexity API key
1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
2. Generate an API key in the dashboard
3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment.

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

@@ -15,15 +15,20 @@ Kimi Coding with `kimi-coding/k2p5`.
Current Kimi K2 model IDs:
[//]: # "moonshot-kimi-k2-ids:start"
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-ids:start _/ && null}
<!-- markdownlint-enable MD037 -->
- `kimi-k2.5`
- `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview`
- `kimi-k2-thinking`
- `kimi-k2-thinking-turbo`
[//]: # "moonshot-kimi-k2-ids:end"
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-ids:end _/ && null}
<!-- markdownlint-enable MD037 -->
```bash
openclaw onboard --auth-choice moonshot-api-key

17
docs/security/README.md Normal file
View File

@@ -0,0 +1,17 @@
# OpenClaw Security & Trust
**Live:** [trust.openclaw.ai](https://trust.openclaw.ai)
## Documents
- [Threat Model](/security/THREAT-MODEL-ATLAS) - MITRE ATLAS-based threat model for the OpenClaw ecosystem
- [Contributing to the Threat Model](/security/CONTRIBUTING-THREAT-MODEL) - How to add threats, mitigations, and attack chains
## Reporting Vulnerabilities
See the [Trust page](https://trust.openclaw.ai) for full reporting instructions covering all repos.
## Contact
- **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) - Security & Trust
- Discord: #security channel

View File

@@ -110,6 +110,48 @@ curl -s -X POST http://127.0.0.1:18791/start
curl -s http://127.0.0.1:18791/tabs
```
## Existing-session MCP on Linux / VPS
If you want Chrome DevTools MCP instead of the managed `openclaw` CDP profile,
you now have two Linux-safe options:
1. Let MCP launch headless Chrome for an `existing-session` profile:
```json
{
"browser": {
"headless": true,
"noSandbox": true,
"executablePath": "/usr/bin/google-chrome-stable",
"defaultProfile": "user"
}
}
```
2. Attach MCP to a running debuggable Chrome instance:
```json
{
"browser": {
"headless": true,
"defaultProfile": "user",
"profiles": {
"user": {
"driver": "existing-session",
"cdpUrl": "http://127.0.0.1:9222",
"color": "#00AA00"
}
}
}
}
```
Notes:
- `driver: "existing-session"` still uses Chrome MCP transport, not the extension relay.
- `cdpUrl` on an `existing-session` profile is interpreted as the MCP browser target (`browserUrl` or `wsEndpoint`), not the normal OpenClaw CDP driver.
- If you omit `cdpUrl`, headless MCP launches Chrome itself.
### Config Reference
| Option | Description | Default |

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.”
@@ -360,9 +359,13 @@ Notes:
## Chrome existing-session via MCP
OpenClaw can also attach to a running Chrome profile through the official
Chrome DevTools MCP server. This reuses the tabs and login state already open in
that Chrome profile.
OpenClaw can also use the official Chrome DevTools MCP server for two different
flows:
- desktop attach via `--autoConnect`, which reuses a running Chrome profile and
its existing tabs/login state
- headless or remote attach, where MCP either launches headless Chrome itself
or connects to a running debuggable browser URL/WS endpoint
Official background and setup references:
@@ -376,7 +379,7 @@ Built-in profile:
Optional: create your own custom existing-session profile if you want a
different name or color.
Then in Chrome:
Desktop attach flow:
1. Open `chrome://inspect/#remote-debugging`
2. Enable remote debugging
@@ -399,30 +402,66 @@ What success looks like:
- `tabs` lists your already-open Chrome tabs
- `snapshot` returns refs from the selected live tab
What to check if attach does not work:
What to check if desktop attach does not work:
- Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt
Headless / Linux / VPS flow:
- Set `browser.headless: true`
- Set `browser.noSandbox: true` when running as root or in common container/VPS setups
- Optional: set `browser.executablePath` to a stable Chrome/Chromium binary path
- Optional: set `browser.profiles.<name>.cdpUrl` on an `existing-session` profile to an
MCP target like `http://127.0.0.1:9222` or
`ws://127.0.0.1:9222/devtools/browser/<id>`
Example:
```json5
{
browser: {
headless: true,
noSandbox: true,
executablePath: "/usr/bin/google-chrome-stable",
defaultProfile: "user",
profiles: {
user: {
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
color: "#00AA00",
},
},
},
}
```
Behavior:
- without `browser.profiles.<name>.cdpUrl`, headless `existing-session` launches Chrome through MCP
- with `browser.profiles.<name>.cdpUrl`, MCP connects to that running browser URL
- non-headless `existing-session` keeps using the interactive `--autoConnect` flow
Agent use:
- Use `profile="user"` when you need the users logged-in browser state.
- If you use a custom existing-session profile, pass that explicit profile name.
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user
explicitly wants the extension / attach-tab flow.
- Only choose this mode when the user is at the computer to approve the attach
prompt.
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
- On desktop `--autoConnect`, only choose this mode when the user is at the
computer to approve the attach prompt.
- The Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
for desktop attach, or use MCP headless/browserUrl/wsEndpoint modes for Linux/VPS paths.
Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session.
- OpenClaw does not launch Chrome for this driver; it attaches to an existing
session only.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
the legacy default-profile remote debugging port workflow.
- OpenClaw uses the official Chrome DevTools MCP server for this driver.
- On desktop, OpenClaw uses MCP `--autoConnect`.
- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a
configured browser URL/WS endpoint.
- Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns

View File

@@ -160,14 +160,13 @@ Long options are validated fail-closed in safe-bin mode: unknown flags and ambig
abbreviations are rejected.
Denied flags by safe-bin profile:
[//]: # "SAFE_BIN_DENIED_FLAGS:START"
<!-- SAFE_BIN_DENIED_FLAGS:START -->
- `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r`
- `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f`
- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o`
- `wc`: `--files0-from`
[//]: # "SAFE_BIN_DENIED_FLAGS:END"
<!-- SAFE_BIN_DENIED_FLAGS:END -->
Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing
and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be

View File

@@ -149,11 +149,7 @@ Lark国际版请使用 https://open.larksuite.com/app并在配置中设
**事件订阅** 页面:
1. 选择 **使用长连接接收事件**WebSocket 模式)
2. 添加事件:
- `im.message.receive_v1`
- `im.message.reaction.created_v1`
- `im.message.reaction.deleted_v1`
- `application.bot.menu_v6`
2. 添加事件:`im.message.receive_v1`(接收消息)
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
@@ -439,7 +435,7 @@ openclaw pairing list feishu
| `/reset` | 重置对话会话 |
| `/model` | 查看/切换模型 |
飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu <eventKey>`)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送
## 网关管理命令
@@ -530,11 +526,7 @@ openclaw pairing list feishu
channels: {
feishu: {
streaming: true, // 启用流式卡片输出(默认 true
blockStreamingCoalesce: {
enabled: true,
minDelayMs: 50,
maxDelayMs: 250,
},
blockStreaming: true, // 启用块级流式(默认 true
},
},
}
@@ -542,40 +534,6 @@ openclaw pairing list feishu
如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`。
### 交互式卡片
OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。
- 默认路径:文本自动渲染或 Markdown 卡片
- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片
- 更新卡片:同一消息支持后续 patch/update
卡片按钮回调当前走文本回退路径:
- 若 `action.value.text` 存在,则作为入站文本继续处理
- 若 `action.value.command` 存在,则作为命令文本继续处理
- 其他对象值会序列化为 JSON 文本
这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。
### 表情反应
飞书渠道现已完整支持表情反应生命周期:
- 接收 `reaction created`
- 接收 `reaction deleted`
- 主动添加反应
- 主动删除自身反应
- 查询消息上的反应列表
是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制:
| 值 | 行为 |
| ----- | ---------------------------- |
| `off` | 不生成反应通知 |
| `own` | 仅当反应发生在机器人消息上时 |
| `all` | 所有可验证的反应都生成通知 |
### 消息引用
在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。
@@ -695,19 +653,14 @@ OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `allowlist` |
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` |
| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
| `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` |
| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` |
| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` |
| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` |
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
---

View File

@@ -57,10 +57,6 @@ export type BlueBubblesAccountConfig = {
allowPrivateNetwork?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>;
/** Channel health monitor overrides for this channel/account. */
healthMonitor?: {
enabled?: boolean;
};
};
export type BlueBubblesActionConfig = {

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

@@ -15,16 +15,9 @@ const {
mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu,
mockGetMessageFeishu,
mockListFeishuThreadMessages,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
mockResolveConfiguredAcpRoute,
mockEnsureConfiguredAcpRouteReady,
mockResolveBoundConversation,
mockTouchBinding,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
@@ -33,7 +26,6 @@ const {
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"),
contentType: "video/mp4",
@@ -48,15 +40,6 @@ const {
mainSessionKey: "agent:main:main",
matchedBy: "default",
})),
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", () => ({
@@ -66,7 +49,6 @@ vi.mock("./reply-dispatcher.js", () => ({
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
listFeishuThreadMessages: mockListFeishuThreadMessages,
}));
vi.mock("./media.js", () => ({
@@ -77,18 +59,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(),
@@ -100,13 +70,11 @@ function createRuntimeEnv(): RuntimeEnv {
}
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
const runtime = createRuntimeEnv();
await handleFeishuMessage({
cfg: params.cfg,
event: params.event,
runtime,
runtime: createRuntimeEnv(),
});
return runtime;
}
describe("buildFeishuAgentBody", () => {
@@ -133,261 +101,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
@@ -427,20 +140,6 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
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",
@@ -467,12 +166,6 @@ describe("handleFeishuMessage command authorization", () => {
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(
() => ({}),
@@ -2016,193 +1709,6 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("bootstraps topic thread context only for a new thread session", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "ou-topic-user",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup_existing_session",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
storePath: "/tmp/feishu-sessions.json",
sessionKey: "agent:main:feishu:dm:ou-attacker",
});
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
expect.objectContaining({
rootMessageId: "om_topic_root",
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("skips topic thread bootstrap when the thread session already exists", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: undefined,
ThreadHistoryBody: undefined,
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "user_topic_1",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic_sender",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-topic-user",
user_id: "user_topic_1",
},
},
message: {
message_id: "om_topic_followup_mixed_ids",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@@ -9,21 +9,12 @@ import {
issuePairingChallenge,
normalizeAgentId,
recordPendingHistoryEntryIfEnabled,
resolveAgentOutboundIdentity,
resolveOpenProviderRuntimeGroupPolicy,
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";
@@ -38,7 +29,7 @@ import {
import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
@@ -281,34 +272,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 +1167,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 +1215,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}`
@@ -1341,17 +1239,16 @@ export async function handleFeishuMessage(params: {
const mediaPayload = buildAgentMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
const quotedMsg = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMessageInfo) {
quotedContent = quotedMessageInfo.content;
if (quotedMsg) {
quotedContent = quotedMsg.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
@@ -1361,11 +1258,6 @@ export async function handleFeishuMessage(params: {
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
@@ -1417,150 +1309,13 @@ export async function handleFeishuMessage(params: {
}))
: undefined;
const threadContextBySessionKey = new Map<
string,
{
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
}
>();
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
let rootMessageFetched = false;
const getRootMessageInfo = async () => {
if (!ctx.rootId) {
return null;
}
if (!rootMessageFetched) {
rootMessageFetched = true;
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
rootMessageInfo = quotedMessageInfo;
} else {
try {
rootMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.rootId,
accountId: account.accountId,
});
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
rootMessageInfo = null;
}
}
}
return rootMessageInfo ?? null;
};
const resolveThreadContextForAgent = async (agentId: string, agentSessionKey: string) => {
const cached = threadContextBySessionKey.get(agentSessionKey);
if (cached) {
return cached;
}
const threadContext: {
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
} = {
threadLabel:
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
? `Feishu thread in ${ctx.chatId}`
: undefined,
};
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: agentSessionKey,
});
if (previousThreadSessionTimestamp) {
log(
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const rootMsg = await getRootMessageInfo();
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
if (feishuThreadId) {
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
}
if (!feishuThreadId) {
log(
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
try {
const threadMessages = await listFeishuThreadMessages({
cfg,
threadId: feishuThreadId,
currentMessageId: ctx.messageId,
rootMessageId: ctx.rootId,
limit: 20,
accountId: account.accountId,
});
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
const senderIds = new Set(
[ctx.senderOpenId, senderUserId]
.map((id) => id?.trim())
.filter((id): id is string => id !== undefined && id.length > 0),
);
const relevantMessages =
(senderScoped
? threadMessages.filter(
(msg) =>
msg.senderType === "app" ||
(msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
)
: threadMessages) ?? [];
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
const historyMessages = includeStarterInHistory
? relevantMessages
: relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime,
body: msg.content,
envelope: envelopeOptions,
});
});
threadContext.threadStarterBody = threadStarterBody;
threadContext.threadHistoryBody =
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
log(
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
);
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
}
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
};
// --- Shared context builder for dispatch ---
const buildCtxPayloadForAgent = async (
agentId: string,
const buildCtxPayloadForAgent = (
agentSessionKey: string,
agentAccountId: string,
wasMentioned: boolean,
) => {
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
return core.channel.reply.finalizeInboundContext({
) =>
core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
@@ -1580,12 +1335,6 @@ export async function handleFeishuMessage(params: {
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
ThreadStarterBody: threadContext.threadStarterBody,
ThreadHistoryBody: threadContext.threadHistoryBody,
ThreadLabel: threadContext.threadLabel,
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
// ID and would produce invalid reply targets downstream.
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
Timestamp: Date.now(),
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
@@ -1594,7 +1343,6 @@ export async function handleFeishuMessage(params: {
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
...mediaPayload,
});
};
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
@@ -1654,8 +1402,7 @@ export async function handleFeishuMessage(params: {
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentCtx = await buildCtxPayloadForAgent(
agentId,
const agentCtx = buildCtxPayloadForAgent(
agentSessionKey,
route.accountId,
ctx.mentionedBot && agentId === activeAgentId,
@@ -1663,7 +1410,6 @@ export async function handleFeishuMessage(params: {
if (agentId === activeAgentId) {
// Active agent: real Feishu dispatcher (responds on Feishu)
const identity = resolveAgentOutboundIdentity(cfg, agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId,
@@ -1676,7 +1422,6 @@ export async function handleFeishuMessage(params: {
threadReply,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
identity,
messageCreateTimeMs,
});
@@ -1757,14 +1502,12 @@ export async function handleFeishuMessage(params: {
);
} else {
// --- Single-agent dispatch (existing behavior) ---
const ctxPayload = await buildCtxPayloadForAgent(
route.agentId,
const ctxPayload = buildCtxPayloadForAgent(
route.sessionKey,
route.accountId,
ctx.mentionedBot,
);
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
@@ -1777,7 +1520,6 @@ export async function handleFeishuMessage(params: {
threadReply,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
identity,
messageCreateTimeMs,
});

View File

@@ -20,20 +20,6 @@ export type FeishuCardActionEvent = {
};
};
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export async function handleFeishuCardAction(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@@ -44,7 +30,21 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
const content = buildCardActionTextFallback(event);
// Extract action value
const actionValue = event.action.value;
let content = "";
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
content = actionValue.text;
} else if ("command" in actionValue && typeof actionValue.command === "string") {
content = actionValue.command;
} else {
content = JSON.stringify(actionValue);
}
} else {
content = String(actionValue);
}
// Construct a synthetic message event
const messageEvent: FeishuMessageEvent = {

View File

@@ -2,18 +2,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./reactions.js", () => ({
addReactionFeishu: vi.fn(),
listReactionsFeishu: listReactionsFeishuMock,
removeReactionFeishu: vi.fn(),
}));
import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => {
@@ -53,114 +46,3 @@ describe("feishuPlugin.status.probeAccount", () => {
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});
describe("feishuPlugin actions", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: true,
},
},
},
} as OpenClawConfig;
it("does not advertise reactions when disabled via actions config", () => {
const disabledCfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: false,
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
});
it("advertises reactions when any enabled configured account allows them", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
defaultAccount: "main",
actions: {
reactions: false,
},
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
actions: {
reactions: false,
},
},
secondary: {
appId: "cli_secondary",
appSecret: "secret_secondary",
enabled: true,
actions: {
reactions: true,
},
},
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
});
it("requires clearAll=true before removing all bot reactions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
});
it("throws for unsupported Feishu send actions without card payload", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", message: "hello" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow('Unsupported Feishu action: "send"');
});
it("allows explicit clearAll=true when removing all bot reactions", async () => {
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r1", operatorType: "app" },
{ reactionId: "r2", operatorType: "app" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
cfg,
accountId: undefined,
} as never);
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
accountId: undefined,
});
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
});
});

View File

@@ -5,23 +5,18 @@ import {
} from "openclaw/plugin-sdk/compat";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
createActionGate,
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/feishu";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
listEnabledFeishuAccounts,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
listFeishuDirectoryPeers,
listFeishuDirectoryGroups,
@@ -32,8 +27,7 @@ import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
import { sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
@@ -48,6 +42,22 @@ const meta: ChannelMeta = {
order: 70,
};
const secretInputJsonSchema = {
oneOf: [
{ type: "string" },
{
type: "object",
additionalProperties: false,
required: ["source", "provider", "id"],
properties: {
source: { type: "string", enum: ["env", "file", "exec"] },
provider: { type: "string", minLength: 1 },
id: { type: "string", minLength: 1 },
},
},
],
} as const;
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
@@ -72,32 +82,6 @@ function setFeishuNamedAccountEnabled(
};
}
function isFeishuReactionsActionEnabled(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
}): boolean {
if (!params.account.enabled || !params.account.configured) {
return false;
}
const gate = createActionGate(
(params.account.config.actions ??
(params.cfg.channels?.feishu as { actions?: unknown } | undefined)?.actions) as Record<
string,
boolean | undefined
>,
);
return gate("reactions");
}
function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
for (const account of listEnabledFeishuAccounts(cfg)) {
if (isFeishuReactionsActionEnabled({ cfg, account })) {
return true;
}
}
return false;
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
@@ -136,7 +120,69 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
defaultAccount: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookHost: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
groupAllowFrom: {
type: "array",
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
},
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
replyInThread: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
chunkMode: { type: "string", enum: ["length", "newline"] },
mediaMaxMb: { type: "number", minimum: 0 },
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
},
},
},
},
},
},
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
@@ -209,172 +255,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
},
actions: {
listActions: ({ cfg }) => {
if (listEnabledFeishuAccounts(cfg).length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>();
if (areAnyFeishuReactionActionsEnabled(cfg)) {
actions.add("react");
actions.add("reactions");
}
return Array.from(actions);
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
);
},
handleAction: async (ctx) => {
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
if (
(ctx.action === "react" || ctx.action === "reactions") &&
!isFeishuReactionsActionEnabled({ cfg: ctx.cfg, account })
) {
throw new Error("Feishu reactions are disabled via actions.reactions.");
}
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }],
details: { error: "Feishu card send requires a target (to)." },
};
}
const replyToMessageId =
typeof ctx.params.replyTo === "string"
? ctx.params.replyTo.trim() || undefined
: undefined;
const result = await sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ ok: true, channel: "feishu", ...result }),
},
],
details: { ok: true, channel: "feishu", ...result },
};
}
if (ctx.action === "react") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reaction requires messageId.");
}
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
const remove = ctx.params.remove === true;
const clearAll = ctx.params.clearAll === true;
if (remove) {
if (!emoji) {
throw new Error("Emoji is required to remove a Feishu reaction.");
}
const matches = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
const ownReaction = matches.find((entry) => entry.operatorType === "app");
if (!ownReaction) {
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) },
],
details: { ok: true, removed: null },
};
}
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: ownReaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) },
],
details: { ok: true, removed: emoji },
};
}
if (!emoji) {
if (!clearAll) {
throw new Error(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
let removed = 0;
for (const reaction of reactions.filter((entry) => entry.operatorType === "app")) {
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: reaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
removed += 1;
}
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }],
details: { ok: true, removed },
};
}
await addReactionFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }],
details: { ok: true, added: emoji },
};
}
if (ctx.action === "reactions") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reactions lookup requires messageId.");
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }],
details: { ok: true, reactions },
};
}
throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`);
},
},
security: {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });

View File

@@ -217,26 +217,6 @@ describe("FeishuConfigSchema optimization flags", () => {
});
});
describe("FeishuConfigSchema actions", () => {
it("accepts top-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
actions: { reactions: false },
});
expect(result.actions?.reactions).toBe(false);
});
it("accepts account-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {
actions: { reactions: false },
},
},
});
expect(result.accounts?.main?.actions?.reactions).toBe(false);
});
});
describe("FeishuConfigSchema defaultAccount", () => {
it("accepts defaultAccount when it matches an account key", () => {
const result = FeishuConfigSchema.safeParse({

View File

@@ -3,13 +3,6 @@ import { z } from "zod";
export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const ChannelActionsSchema = z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional();
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.union([
z.enum(["open", "allowlist", "disabled"]),
@@ -177,7 +170,6 @@ const FeishuSharedConfigShape = {
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
actions: ChannelActionsSchema,
replyInThread: ReplyInThreadSchema,
reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(),

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;
@@ -39,10 +38,6 @@ export type FeishuReactionCreatedEvent = {
action_time?: string;
};
export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
reaction_id?: string;
};
type ResolveReactionSyntheticEventParams = {
cfg: ClawdbotConfig;
accountId: string;
@@ -52,7 +47,6 @@ type ResolveReactionSyntheticEventParams = {
verificationTimeoutMs?: number;
logger?: (message: string) => void;
uuid?: () => string;
action?: "created" | "deleted";
};
export async function resolveReactionSyntheticEvent(
@@ -67,7 +61,6 @@ export async function resolveReactionSyntheticEvent(
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
logger,
uuid = () => crypto.randomUUID(),
action = "created",
} = params;
const emoji = event.reaction_type?.emoji_type;
@@ -136,10 +129,7 @@ export async function resolveReactionSyntheticEvent(
chat_type: syntheticChatType,
message_type: "text",
content: JSON.stringify({
text:
action === "deleted"
? `[removed reaction ${emoji} from message ${messageId}]`
: `[reacted with ${emoji} to message ${messageId}]`,
text: `[reacted with ${emoji} to message ${messageId}]`,
}),
},
};
@@ -263,19 +253,6 @@ function registerEventHandlers(
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const enqueue = createChatQueue();
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
if (fireAndForget) {
void params.task().catch((err) => {
error(`${params.errorMessage}: ${String(err)}`);
});
return;
}
try {
await params.task();
} catch (err) {
error(`${params.errorMessage}: ${String(err)}`);
}
};
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
const chatId = event.message.chat_id?.trim() || "unknown";
const task = () =>
@@ -451,102 +428,23 @@ function registerEventHandlers(
}
},
"im.message.reaction.created_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction event`,
task: async () => {
const event = data as FeishuReactionCreatedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"im.message.reaction.deleted_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
task: async () => {
const event = data as FeishuReactionDeletedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
action: "deleted",
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"application.bot.menu_v6": async (data) => {
try {
const event = data as {
event_key?: string;
timestamp?: number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
const eventKey = event.event_key?.trim();
if (!operatorOpenId || !eventKey) {
const processReaction = async () => {
const event = data as FeishuReactionCreatedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
});
if (!syntheticEvent) {
return;
}
const syntheticEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: operatorOpenId,
user_id: event.operator?.operator_id?.user_id,
union_id: event.operator?.operator_id?.union_id,
},
sender_type: "user",
},
message: {
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
chat_id: `p2p:${operatorOpenId}`,
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({
text: `/menu ${eventKey}`,
}),
},
};
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
@@ -554,14 +452,28 @@ function registerEventHandlers(
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
});
return;
}
await promise;
} catch (err) {
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
};
if (fireAndForget) {
void processReaction().catch((err) => {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
});
return;
}
try {
await processReaction();
} catch (err) {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
}
},
"im.message.reaction.deleted_v1": async () => {
// Ignore reaction removals
},
"card.action.trigger": async (data: unknown) => {
try {
@@ -632,25 +544,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

@@ -1,67 +0,0 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it } from "vitest";
import {
resolveReactionSyntheticEvent,
type FeishuReactionCreatedEvent,
} from "./monitor.account.js";
const cfg = {} as ClawdbotConfig;
function makeReactionEvent(
overrides: Partial<FeishuReactionCreatedEvent> = {},
): FeishuReactionCreatedEvent {
return {
message_id: "om_msg1",
reaction_type: { emoji_type: "THUMBSUP" },
operator_type: "user",
user_id: { open_id: "ou_user1" },
...overrides,
};
}
describe("Feishu reaction lifecycle", () => {
it("builds a created synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
});
expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
});
it("builds a deleted synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
action: "deleted",
});
expect(result?.message.content).toBe(
'{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
);
});
});

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

@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock,
@@ -15,7 +14,6 @@ vi.mock("./media.js", () => ({
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
}));
vi.mock("./runtime.js", () => ({
@@ -35,7 +33,6 @@ function resetOutboundMocks() {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
}
@@ -135,7 +132,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
accountId: "main",
});
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1",
text: "| a | b |\n| - | - |",
@@ -210,7 +207,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
);
});
it("forwards replyToId to sendStructuredCardFeishu when renderMode=card", async () => {
it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => {
await sendText({
cfg: {
channels: {
@@ -225,7 +222,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
accountId: "main",
});
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_reply_target",
}),

View File

@@ -4,7 +4,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
const raw = text?.trim();
@@ -81,16 +81,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({
cfg,
to,
text,
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
@@ -113,29 +104,6 @@ export const feishuOutbound: ChannelOutboundAdapter = {
}
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
const result = await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
return { channel: "feishu", ...result };
}
const result = await sendOutboundText({
cfg,
to,

View File

@@ -4,7 +4,6 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
@@ -18,7 +17,6 @@ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
}));
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
@@ -58,7 +56,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
vi.clearAllMocks();
streamingInstances.length = 0;
sendMediaFeishuMock.mockResolvedValue(undefined);
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
@@ -258,17 +255,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: undefined,
replyInThread: undefined,
rootId: "om_root_topic",
header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
replyToMessageId: undefined,
replyInThread: undefined,
rootId: "om_root_topic",
});
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
@@ -284,9 +275,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
note: "Agent: agent",
});
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
});
it("delivers distinct final payloads after streaming close", async () => {
@@ -298,16 +287,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(2);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```", {
note: "Agent: agent",
});
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[1].close).toHaveBeenCalledWith(
"```md\n完整回复第一段 + 第二段\n```",
{
note: "Agent: agent",
},
);
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
@@ -321,9 +303,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
note: "Agent: agent",
});
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
@@ -387,9 +367,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
note: "Agent: agent",
});
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
});
it("sends media-only payloads as attachments", async () => {
@@ -458,7 +436,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
});
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
@@ -476,7 +454,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
await options.deliver({ text: "card text" }, { kind: "final" });
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
@@ -484,126 +462,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
});
it("streams reasoning content as blockquote before answer", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
// Core agent sends pre-formatted text from formatReasoningMessage
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" });
result.replyOptions.onReasoningStream?.({
text: "Reasoning:\n_thinking step 1_\n_step 2_",
});
result.replyOptions.onPartialReply?.({ text: "answer part" });
result.replyOptions.onReasoningEnd?.();
await options.deliver({ text: "answer part final" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) => c[0]);
const reasoningUpdate = updateCalls.find((c: string) => c.includes("Thinking"));
expect(reasoningUpdate).toContain("> 💭 **Thinking**");
// formatReasoningPrefix strips "Reasoning:" prefix and italic markers
expect(reasoningUpdate).toContain("> thinking step");
expect(reasoningUpdate).not.toContain("Reasoning:");
expect(reasoningUpdate).not.toMatch(/> _.*_/);
const combinedUpdate = updateCalls.find(
(c: string) => c.includes("Thinking") && c.includes("---"),
);
expect(combinedUpdate).toBeDefined();
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).toContain("> 💭 **Thinking**");
expect(closeArg).toContain("---");
expect(closeArg).toContain("answer part final");
});
it("provides onReasoningStream and onReasoningEnd when streaming is enabled", () => {
const { result } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
});
it("omits reasoning callbacks when streaming is disabled", () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: false,
},
});
const { result } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
expect(result.replyOptions.onReasoningStream).toBeUndefined();
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
});
it("renders reasoning-only card when no answer text arrives", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" });
result.replyOptions.onReasoningEnd?.();
await options.onIdle?.();
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).toContain("> 💭 **Thinking**");
expect(closeArg).toContain("> deep thought");
expect(closeArg).not.toContain("Reasoning:");
expect(closeArg).not.toContain("---");
});
it("ignores empty reasoning payloads", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "" });
result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).not.toContain("Thinking");
expect(closeArg).toBe("```ts\ncode\n```");
});
it("deduplicates final text by raw answer payload, not combined card text", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" });
result.replyOptions.onReasoningEnd?.();
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
// Deliver the same raw answer text again — should be deduped
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
// No second streaming session since the raw answer text matches
expect(streamingInstances).toHaveLength(1);
});
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
const { options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
@@ -613,16 +471,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
replyToMessageId: "om_msg",
replyInThread: true,
});
});
it("disables streaming for thread replies and keeps reply metadata", async () => {
@@ -636,7 +488,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(0);
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,

View File

@@ -3,7 +3,6 @@ import {
createTypingCallbacks,
logTypingFailure,
type ClawdbotConfig,
type OutboundIdentity,
type ReplyPayload,
type RuntimeEnv,
} from "openclaw/plugin-sdk/feishu";
@@ -13,12 +12,7 @@ import { sendMediaFeishu } from "./media.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import {
sendMarkdownCardFeishu,
sendMessageFeishu,
sendStructuredCardFeishu,
type CardHeaderConfig,
} from "./send.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@@ -42,36 +36,6 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
}
/** Build a card header from agent identity config. */
function resolveCardHeader(
agentId: string,
identity: OutboundIdentity | undefined,
): CardHeaderConfig {
const name = identity?.name?.trim() || agentId;
const emoji = identity?.emoji?.trim();
return {
title: emoji ? `${emoji} ${name}` : name,
template: identity?.theme ?? "blue",
};
}
/** Build a card note footer from agent identity and model context. */
function resolveCardNote(
agentId: string,
identity: OutboundIdentity | undefined,
prefixCtx: { model?: string; provider?: string },
): string {
const name = identity?.name?.trim() || agentId;
const parts: string[] = [`Agent: ${name}`];
if (prefixCtx.model) {
parts.push(`Model: ${prefixCtx.model}`);
}
if (prefixCtx.provider) {
parts.push(`Provider: ${prefixCtx.provider}`);
}
return parts.join(" | ");
}
export type CreateFeishuReplyDispatcherParams = {
cfg: ClawdbotConfig;
agentId: string;
@@ -86,7 +50,6 @@ export type CreateFeishuReplyDispatcherParams = {
rootId?: string;
mentionTargets?: MentionTarget[];
accountId?: string;
identity?: OutboundIdentity;
/** Epoch ms when the inbound message was created. Used to suppress typing
* indicators on old/replayed messages after context compaction (#30418). */
messageCreateTimeMs?: number;
@@ -105,7 +68,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
rootId,
mentionTargets,
accountId,
identity,
} = params;
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const threadReplyMode = threadReply === true;
@@ -181,39 +143,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
let streaming: FeishuStreamingSession | null = null;
let streamText = "";
let lastPartial = "";
let reasoningText = "";
const deliveredFinalTexts = new Set<string>();
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null;
type StreamTextUpdateMode = "snapshot" | "delta";
const formatReasoningPrefix = (thinking: string): string => {
if (!thinking) return "";
const withoutLabel = thinking.replace(/^Reasoning:\n/, "");
const plain = withoutLabel.replace(/^_(.*)_$/gm, "$1");
const lines = plain.split("\n").map((line) => `> ${line}`);
return `> 💭 **Thinking**\n${lines.join("\n")}`;
};
const buildCombinedStreamText = (thinking: string, answer: string): string => {
const parts: string[] = [];
if (thinking) parts.push(formatReasoningPrefix(thinking));
if (thinking && answer) parts.push("\n\n---\n\n");
if (answer) parts.push(answer);
return parts.join("");
};
const flushStreamingCardUpdate = (combined: string) => {
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(combined);
}
});
};
const queueStreamingUpdate = (
nextText: string,
options?: {
@@ -233,13 +167,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const mode = options?.mode ?? "snapshot";
streamText =
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
};
const queueReasoningUpdate = (nextThinking: string) => {
if (!nextThinking) return;
reasoningText = nextThinking;
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(streamText);
}
});
};
const startStreaming = () => {
@@ -259,14 +194,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
);
try {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await streaming.start(chatId, resolveReceiveIdType(chatId), {
replyToMessageId,
replyInThread: effectiveReplyInThread,
rootId,
header: cardHeader,
note: cardNote,
});
} catch (error) {
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
@@ -282,18 +213,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
await partialUpdateQueue;
if (streaming?.isActive()) {
let text = buildCombinedStreamText(reasoningText, streamText);
let text = streamText;
if (mentionTargets?.length) {
text = buildMentionedCardContent(mentionTargets, text);
}
const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await streaming.close(text, { note: finalNote });
await streaming.close(text);
}
streaming = null;
streamingStartPromise = null;
streamText = "";
lastPartial = "";
reasoningText = "";
};
const sendChunkedTextReply = async (params: {
@@ -363,7 +292,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (shouldDeliverText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
let first = true;
if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as
@@ -412,29 +340,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
if (useCard) {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
for (const chunk of core.channel.text.chunkTextWithMode(
text,
textChunkLimit,
chunkMode,
)) {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
first = false;
}
if (info?.kind === "final") {
deliveredFinalTexts.add(text);
}
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
} else {
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
}
@@ -486,16 +392,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
});
}
: undefined,
onReasoningStream: streamingEnabled
? (payload: ReplyPayload) => {
if (!payload.text) {
return;
}
startStreaming();
queueReasoningUpdate(payload.text);
}
: undefined,
onReasoningEnd: streamingEnabled ? () => {} : undefined,
},
markDispatchIdle,
};

View File

@@ -1,19 +1,12 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildStructuredCard,
getMessageFeishu,
listFeishuThreadMessages,
resolveFeishuCardTemplate,
} from "./send.js";
import { getMessageFeishu } from "./send.js";
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockClientList: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
@@ -34,7 +27,6 @@ describe("getMessageFeishu", () => {
im: {
message: {
get: mockClientGet,
list: mockClientList,
},
},
});
@@ -173,98 +165,4 @@ describe("getMessageFeishu", () => {
}),
);
});
it("reuses the same content parsing for thread history messages", async () => {
mockClientList.mockResolvedValueOnce({
code: 0,
data: {
items: [
{
message_id: "om_root",
msg_type: "text",
body: {
content: JSON.stringify({ text: "root starter" }),
},
},
{
message_id: "om_card",
msg_type: "interactive",
body: {
content: JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
},
}),
},
sender: {
id: "app_1",
sender_type: "app",
},
create_time: "1710000000000",
},
{
message_id: "om_file",
msg_type: "file",
body: {
content: JSON.stringify({ file_key: "file_v3_123" }),
},
sender: {
id: "ou_1",
sender_type: "user",
},
create_time: "1710000001000",
},
],
},
});
const result = await listFeishuThreadMessages({
cfg: {} as ClawdbotConfig,
threadId: "omt_1",
rootMessageId: "om_root",
});
expect(result).toEqual([
expect.objectContaining({
messageId: "om_file",
contentType: "file",
content: "[file message]",
}),
expect.objectContaining({
messageId: "om_card",
contentType: "interactive",
content: "hello from card 2.0",
}),
]);
});
});
describe("resolveFeishuCardTemplate", () => {
it("accepts supported Feishu templates", () => {
expect(resolveFeishuCardTemplate(" purple ")).toBe("purple");
});
it("drops unsupported free-form identity themes", () => {
expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined();
});
});
describe("buildStructuredCard", () => {
it("falls back to blue when the header template is unsupported", () => {
const card = buildStructuredCard("hello", {
header: {
title: "Agent",
template: "space lobster",
},
});
expect(card).toEqual(
expect.objectContaining({
header: {
title: { tag: "plain_text", content: "Agent" },
template: "blue",
},
}),
);
});
});

View File

@@ -10,21 +10,6 @@ import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
const FEISHU_CARD_TEMPLATES = new Set([
"blue",
"green",
"red",
"orange",
"purple",
"indigo",
"wathet",
"turquoise",
"yellow",
"grey",
"carmine",
"violet",
"lime",
]);
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
@@ -80,7 +65,6 @@ type FeishuMessageGetItem = {
message_id?: string;
chat_id?: string;
chat_type?: FeishuChatType;
thread_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: FeishuMessageSender;
@@ -167,19 +151,13 @@ function parseInteractiveCardContent(parsed: unknown): string {
return "[Interactive Card]";
}
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
const elements = Array.isArray(candidate.elements)
? candidate.elements
: Array.isArray(candidate.body?.elements)
? candidate.body!.elements
: null;
if (!elements) {
const candidate = parsed as { elements?: unknown };
if (!Array.isArray(candidate.elements)) {
return "[Interactive Card]";
}
const texts: string[] = [];
for (const element of elements) {
for (const element of candidate.elements) {
if (!element || typeof element !== "object") {
continue;
}
@@ -199,7 +177,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
return texts.join("\n").trim() || "[Interactive Card]";
}
function parseFeishuMessageContent(rawContent: string, msgType: string): string {
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
if (!rawContent) {
return "";
}
@@ -240,30 +218,6 @@ function parseFeishuMessageContent(rawContent: string, msgType: string): string
return `[${msgType || "unknown"} message]`;
}
function parseFeishuMessageItem(
item: FeishuMessageGetItem,
fallbackMessageId?: string,
): FeishuMessageInfo {
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
return {
messageId: item.message_id ?? fallbackMessageId ?? "",
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content: parseFeishuMessageContent(rawContent, msgType),
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
threadId: item.thread_id || undefined,
};
}
/**
* Get a message by its ID.
* Useful for fetching quoted/replied message content.
@@ -301,98 +255,29 @@ export async function getMessageFeishu(params: {
return null;
}
return parseFeishuMessageItem(item, messageId);
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
const content = parseQuotedMessageContent(rawContent, msgType);
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content,
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
};
} catch {
return null;
}
}
export type FeishuThreadMessageInfo = {
messageId: string;
senderId?: string;
senderType?: string;
content: string;
contentType: string;
createTime?: number;
};
/**
* List messages in a Feishu thread (topic).
* Uses container_id_type=thread to directly query thread messages,
* which includes both the root message and all replies (including bot replies).
*/
export async function listFeishuThreadMessages(params: {
cfg: ClawdbotConfig;
threadId: string;
currentMessageId?: string;
/** Exclude the root message (already provided separately as ThreadStarterBody). */
rootMessageId?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuThreadMessageInfo[]> {
const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = (await client.im.message.list({
params: {
container_id_type: "thread",
container_id: threadId,
// Fetch newest messages first so long threads keep the most recent turns.
// Results are reversed below to restore chronological order.
sort_type: "ByCreateTimeDesc",
page_size: Math.min(limit + 1, 50),
},
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<
{
message_id?: string;
root_id?: string;
parent_id?: string;
} & FeishuMessageGetItem
>;
};
};
if (response.code !== 0) {
throw new Error(
`Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
);
}
const items = response.data?.items ?? [];
const results: FeishuThreadMessageInfo[] = [];
for (const item of items) {
if (currentMessageId && item.message_id === currentMessageId) continue;
if (rootMessageId && item.message_id === rootMessageId) continue;
const parsed = parseFeishuMessageItem(item);
results.push({
messageId: parsed.messageId,
senderId: parsed.senderId,
senderType: parsed.senderType,
content: parsed.content,
contentType: parsed.contentType,
createTime: parsed.createTime,
});
if (results.length >= limit) break;
}
// Restore chronological order (oldest first) since we fetched newest-first.
results.reverse();
return results;
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;
@@ -533,77 +418,6 @@ export function buildMarkdownCard(text: string): Record<string, unknown> {
};
}
/** Header configuration for structured Feishu cards. */
export type CardHeaderConfig = {
/** Header title text, e.g. "💻 Coder" */
title: string;
/** Feishu header color template (blue, green, red, orange, purple, grey, etc.). Defaults to "blue". */
template?: string;
};
export function resolveFeishuCardTemplate(template?: string): string | undefined {
const normalized = template?.trim().toLowerCase();
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
return undefined;
}
return normalized;
}
/**
* Build a Feishu interactive card with optional header and note footer.
* When header/note are omitted, behaves identically to buildMarkdownCard.
*/
export function buildStructuredCard(
text: string,
options?: {
header?: CardHeaderConfig;
note?: string;
},
): Record<string, unknown> {
const elements: Record<string, unknown>[] = [{ tag: "markdown", content: text }];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({ tag: "markdown", content: `<font color='grey'>${options.note}</font>` });
}
const card: Record<string, unknown> = {
schema: "2.0",
config: { wide_screen_mode: true },
body: { elements },
};
if (options?.header) {
card.header = {
title: { tag: "plain_text", content: options.header.title },
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
};
}
return card;
}
/**
* Send a message as a structured card with optional header and note.
*/
export async function sendStructuredCardFeishu(params: {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
mentions?: MentionTarget[];
accountId?: string;
header?: CardHeaderConfig;
note?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } =
params;
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildStructuredCard(cardText, { header, note });
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
}
/**
* Send a message as a markdown card (interactive message).
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)

View File

@@ -4,25 +4,10 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import type { FeishuDomain } from "./types.js";
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = {
cardId: string;
messageId: string;
sequence: number;
currentText: string;
hasNote: boolean;
};
/** Options for customising the initial streaming card appearance. */
export type StreamingCardOptions = {
/** Optional header with title and color template. */
header?: CardHeaderConfig;
/** Optional grey note footer text. */
note?: string;
};
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
/** Optional header for streaming cards (title bar with color template) */
export type StreamingCardHeader = {
@@ -167,7 +152,6 @@ export class FeishuStreamingSession {
private log?: (msg: string) => void;
private lastUpdateTime = 0;
private pendingText: string | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private updateThrottleMs = 100; // Throttle updates to max 10/sec
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
@@ -179,24 +163,13 @@ export class FeishuStreamingSession {
async start(
receiveId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
options?: StreamingCardOptions & StreamingStartOptions,
options?: StreamingStartOptions,
): Promise<void> {
if (this.state) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
const elements: Record<string, unknown>[] = [
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({
tag: "markdown",
content: `<font color='grey'>${options.note}</font>`,
element_id: "note",
});
}
const cardJson: Record<string, unknown> = {
schema: "2.0",
config: {
@@ -204,12 +177,14 @@ export class FeishuStreamingSession {
summary: { content: "[Generating...]" },
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
},
body: { elements },
body: {
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
},
};
if (options?.header) {
cardJson.header = {
title: { tag: "plain_text", content: options.header.title },
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
template: options.header.template ?? "blue",
};
}
@@ -282,13 +257,7 @@ export class FeishuStreamingSession {
throw new Error(`Send card failed: ${sendRes.msg}`);
}
this.state = {
cardId,
messageId: sendRes.data.message_id,
sequence: 1,
currentText: "",
hasNote: !!options?.note,
};
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
}
@@ -338,10 +307,6 @@ export class FeishuStreamingSession {
}
this.pendingText = null;
this.lastUpdateTime = now;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.queue = this.queue.then(async () => {
if (!this.state || this.closed) {
@@ -357,44 +322,11 @@ export class FeishuStreamingSession {
await this.queue;
}
private async updateNoteContent(note: string): Promise<void> {
if (!this.state || !this.state.hasNote) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
await fetchWithSsrFGuard({
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
init: {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: `<font color='grey'>${note}</font>`,
sequence: this.state.sequence,
uuid: `n_${this.state.cardId}_${this.state.sequence}`,
}),
},
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.note-update",
})
.then(async ({ release }) => {
await release();
})
.catch((e) => this.log?.(`Note update failed: ${String(e)}`));
}
async close(finalText?: string, options?: { note?: string }): Promise<void> {
async close(finalText?: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
this.closed = true;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
await this.queue;
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
@@ -407,11 +339,6 @@ export class FeishuStreamingSession {
this.state.currentText = text;
}
// Update note with final model/provider info
if (options?.note) {
await this.updateNoteContent(options.note);
}
// Close streaming mode
this.state.sequence += 1;
await fetchWithSsrFGuard({
@@ -437,11 +364,8 @@ export class FeishuStreamingSession {
await release();
})
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
const finalState = this.state;
this.state = null;
this.pendingText = null;
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
}
isActive(): boolean {

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

@@ -72,8 +72,6 @@ export type FeishuMessageInfo = {
content: string;
contentType: string;
createTime?: number;
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
threadId?: string;
};
export type FeishuProbeResult = BaseProbeResult<string> & {

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(

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