mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 15:01:17 +08:00
Compare commits
4 Commits
fix-oom-co
...
docs/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716fbdad5d | ||
|
|
4f158ef917 | ||
|
|
8075e5e0b9 | ||
|
|
24b19b8624 |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -212,13 +212,6 @@ class DeviceHandler(
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"callLog",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"motion",
|
||||
permissionStateJson(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,6 @@ class DeviceHandlerTest {
|
||||
"photos",
|
||||
"contacts",
|
||||
"calendar",
|
||||
"callLog",
|
||||
"motion",
|
||||
)
|
||||
for (key in expected) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
|
||||
|
||||
## What’s implemented (2025-12-03)
|
||||
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. 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 bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. 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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
250
docs/concepts/context-engine.md
Normal file
250
docs/concepts/context-engine.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 409–515 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 409–515 → relink with `openclaw channels logout` then `openclaw channels login`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -285,7 +285,6 @@ Available families:
|
||||
- `photos.latest`
|
||||
- `contacts.search`, `contacts.add`
|
||||
- `calendar.events`, `calendar.add`
|
||||
- `callLog.search`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
|
||||
Example invokes:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
17
docs/security/README.md
Normal 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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 user’s 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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> & {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user