mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 06:21:32 +08:00
Compare commits
3 Commits
fix-oom-co
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3f25ae75 | ||
|
|
7b3630e310 | ||
|
|
c71fb8cda0 |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -6,63 +6,33 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
|
||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
|
||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
|
||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
|
||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
|
||||
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
|
||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
||||
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
||||
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
||||
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,28 +92,20 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
|
||||
@@ -121,7 +121,6 @@ private enum class PermissionToggle {
|
||||
Calendar,
|
||||
Motion,
|
||||
Sms,
|
||||
CallLog,
|
||||
}
|
||||
|
||||
private enum class SpecialAccessToggle {
|
||||
@@ -289,10 +288,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
rememberSaveable {
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
var enableCallLog by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
|
||||
@@ -309,7 +304,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||
PermissionToggle.CallLog -> enableCallLog = enabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +331,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
@@ -359,7 +352,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
enableCalendar,
|
||||
enableMotion,
|
||||
enableSms,
|
||||
enableCallLog,
|
||||
smsAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
@@ -375,7 +367,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
if (enableCalendar) enabled += "Calendar"
|
||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||
if (smsAvailable && enableSms) enabled += "SMS"
|
||||
if (enableCallLog) enabled += "Call Log"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
@@ -464,28 +455,19 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = onboardingSurface,
|
||||
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
@@ -604,7 +586,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
enableCallLog = enableCallLog,
|
||||
context = context,
|
||||
onDiscoveryChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
@@ -702,13 +683,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
},
|
||||
onCallLogChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.CallLog,
|
||||
checked,
|
||||
listOf(Manifest.permission.READ_CALL_LOG),
|
||||
)
|
||||
},
|
||||
)
|
||||
OnboardingStep.FinalCheck ->
|
||||
FinalStep(
|
||||
@@ -1299,7 +1273,6 @@ private fun PermissionsStep(
|
||||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
enableCallLog: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
onLocationChange: (Boolean) -> Unit,
|
||||
@@ -1312,7 +1285,6 @@ private fun PermissionsStep(
|
||||
onCalendarChange: (Boolean) -> Unit,
|
||||
onMotionChange: (Boolean) -> Unit,
|
||||
onSmsChange: (Boolean) -> Unit,
|
||||
onCallLogChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
val locationGranted =
|
||||
@@ -1443,15 +1415,6 @@ private fun PermissionsStep(
|
||||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Call Log",
|
||||
subtitle = "callLog.search",
|
||||
checked = enableCallLog,
|
||||
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
||||
onCheckedChange = onCallLogChange,
|
||||
)
|
||||
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,18 +218,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
calendarPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var callLogPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val callLogPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
callLogPermissionGranted = granted
|
||||
}
|
||||
|
||||
var motionPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
@@ -278,9 +266,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
callLogPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
motionPermissionGranted =
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
@@ -616,31 +601,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Call Log", style = mobileHeadline) },
|
||||
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (callLogPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (callLogPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (motionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
@@ -822,7 +782,7 @@ private fun openNotificationListenerSettings(context: Context) {
|
||||
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 33) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
@@ -832,5 +792,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
@@ -128,15 +128,7 @@ fun ChatComposer(
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showThinkingMenu,
|
||||
onDismissRequest = { showThinkingMenu = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = mobileCardSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
|
||||
@@ -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":4733}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -101,7 +101,6 @@
|
||||
{"recordType":"path","path":"agents.defaults.compaction.recentTurnsPreserve","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Preserve Recent Turns","help":"Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.reserveTokens","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Tokens","help":"Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.reserveTokensFloor","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Token Floor","help":"Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Compaction Timeout (Seconds)","help":"Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -144,7 +143,7 @@
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false}
|
||||
@@ -348,7 +347,7 @@
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -913,8 +912,6 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1168,8 +1165,6 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1285,182 +1280,61 @@
|
||||
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.resolveSenderNames","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.typingIndicator","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":true,"enumValues":["websocket","webhook"],"defaultValue":"websocket","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","pairing","allowlist"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":true,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.agentDirTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.maxAgents","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.workspaceTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.typingIndicator","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -1468,7 +1342,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1504,8 +1377,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1530,7 +1401,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1567,8 +1437,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1636,8 +1504,6 @@
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1699,8 +1565,6 @@
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2105,8 +1969,6 @@
|
||||
{"recordType":"path","path":"channels.msteams.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.groupAllowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2352,8 +2214,6 @@
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2422,8 +2282,6 @@
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2528,8 +2386,6 @@
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2653,8 +2509,6 @@
|
||||
{"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2834,8 +2688,6 @@
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3010,8 +2862,6 @@
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3182,8 +3032,6 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3247,8 +3095,6 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3484,8 +3330,6 @@
|
||||
{"recordType":"path","path":"gateway.auth.trustedProxy.userHeader","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.bind","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Bind Mode","help":"Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelHealthCheckMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Gateway Channel Health Check Interval (min)","help":"Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3740,7 +3584,7 @@
|
||||
{"recordType":"path","path":"messages.ackReactionScope","kind":"core","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","off","none"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Ack Reaction Scope","help":"When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.","hasChildren":false}
|
||||
{"recordType":"path","path":"messages.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Chat Rules","help":"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Group History Limit","help":"Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.","hasChildren":false}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"messages.inbound","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce","help":"Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.inbound.byChannel","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce by Channel (ms)","help":"Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.","hasChildren":true}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 64 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 64 KiB |
@@ -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).
|
||||
|
||||
@@ -655,12 +655,12 @@ See the full channel index: [Channels](/channels).
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
Group messages default to **require mention** (metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
|
||||
**Mention types:**
|
||||
|
||||
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
|
||||
- **Text patterns**: Safe regex patterns in `agents.list[].groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored.
|
||||
- **Text patterns**: Regex patterns in `agents.list[].groupChat.mentionPatterns`. Always checked.
|
||||
- Mention gating is enforced only when detection is possible (native mentions or at least one pattern).
|
||||
|
||||
```json5
|
||||
@@ -1005,7 +1005,6 @@ Periodic heartbeat runs.
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
timeoutSeconds: 900,
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
@@ -1024,7 +1023,6 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
@@ -2372,7 +2370,6 @@ See [Plugins](/tools/plugin).
|
||||
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
|
||||
- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model).
|
||||
- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation.
|
||||
- In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks.
|
||||
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
|
||||
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
||||
@@ -2490,11 +2487,6 @@ See [Plugins](/tools/plugin).
|
||||
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
|
||||
- `gateway.channelHealthCheckMinutes`: channel health-monitor interval in minutes. Set `0` to disable health-monitor restarts globally. Default: `5`.
|
||||
- `gateway.channelStaleEventThresholdMinutes`: stale-socket threshold in minutes. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. Default: `30`.
|
||||
- `gateway.channelMaxRestartsPerHour`: maximum health-monitor restarts per channel/account in a rolling hour. Default: `10`.
|
||||
- `channels.<provider>.healthMonitor.enabled`: per-channel opt-out for health-monitor restarts while keeping the global monitor enabled.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: per-account override for multi-account channels. When set, it takes precedence over the channel-level override.
|
||||
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
|
||||
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -285,7 +285,6 @@ Available families:
|
||||
- `photos.latest`
|
||||
- `contacts.search`, `contacts.add`
|
||||
- `calendar.events`, `calendar.add`
|
||||
- `callLog.search`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
|
||||
Example invokes:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.”
|
||||
|
||||
@@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
|
||||
|
||||
const payload: SetupPayload = {
|
||||
url: urlResult.url,
|
||||
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
};
|
||||
|
||||
if (action === "qr") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,10 +21,6 @@ const {
|
||||
mockResolveAgentRoute,
|
||||
mockReadSessionUpdatedAt,
|
||||
mockResolveStorePath,
|
||||
mockResolveConfiguredAcpRoute,
|
||||
mockEnsureConfiguredAcpRouteReady,
|
||||
mockResolveBoundConversation,
|
||||
mockTouchBinding,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||
dispatcher: vi.fn(),
|
||||
@@ -50,13 +46,6 @@ const {
|
||||
})),
|
||||
mockReadSessionUpdatedAt: vi.fn(),
|
||||
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
})),
|
||||
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
||||
mockResolveBoundConversation: vi.fn(() => null),
|
||||
mockTouchBinding: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-dispatcher.js", () => ({
|
||||
@@ -77,18 +66,6 @@ vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({
|
||||
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
|
||||
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
resolveByConversation: mockResolveBoundConversation,
|
||||
touch: mockTouchBinding,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
@@ -133,261 +110,6 @@ describe("buildFeishuAgentBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage ACP routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReset().mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:feishu:direct:ou_sender_1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
});
|
||||
mockSendMessageFeishu
|
||||
.mockReset()
|
||||
.mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
|
||||
mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
} as any,
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
});
|
||||
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
session: {
|
||||
readSessionUpdatedAt:
|
||||
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
resolveStorePath:
|
||||
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(
|
||||
() => ({}),
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
||||
finalizeInboundContext: ((ctx: unknown) =>
|
||||
ctx) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
dispatchReplyFromConfig: vi.fn().mockResolvedValue({
|
||||
queuedFinal: false,
|
||||
counts: { final: 1 },
|
||||
}),
|
||||
withReplyDispatcher: vi.fn(
|
||||
async ({
|
||||
run,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) =>
|
||||
await run(),
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
|
||||
upsertPairingRequest: vi.fn(),
|
||||
buildPairingReply: vi.fn(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ensures configured ACP routes for Feishu DMs", async () => {
|
||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: { source: "config" },
|
||||
},
|
||||
},
|
||||
route: {
|
||||
agentId: "codex",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-1",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: { source: "config" },
|
||||
},
|
||||
},
|
||||
route: {
|
||||
agentId: "codex",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "runtime unavailable",
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-2",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc_dm",
|
||||
text: expect.stringContaining("runtime unavailable"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Feishu topic messages through active bound conversations", async () => {
|
||||
mockResolveBoundConversation.mockReturnValue({
|
||||
bindingId: "default:oc_group_chat:topic:om_topic_root",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
allowFrom: ["ou_sender_1"],
|
||||
groups: {
|
||||
oc_group_chat: {
|
||||
allow: true,
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-3",
|
||||
chat_id: "oc_group_chat",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
root_id: "om_topic_root",
|
||||
content: JSON.stringify({ text: "hello topic" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockResolveBoundConversation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "feishu",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
);
|
||||
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage command authorization", () => {
|
||||
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
@@ -431,16 +153,6 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
|
||||
@@ -14,16 +14,8 @@ import {
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
} from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { buildFeishuConversationId } from "./conversation-id.js";
|
||||
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
@@ -281,34 +273,15 @@ function resolveFeishuGroupSession(params: {
|
||||
let peerId = chatId;
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
peerId = `${chatId}:sender:${senderOpenId}`;
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic",
|
||||
topicId: topicScope,
|
||||
})
|
||||
: chatId;
|
||||
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: topicScope,
|
||||
senderOpenId,
|
||||
})
|
||||
: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
||||
: `${chatId}:sender:${senderOpenId}`;
|
||||
break;
|
||||
case "group":
|
||||
default:
|
||||
@@ -1195,10 +1168,6 @@ export async function handleFeishuMessage(params: {
|
||||
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
||||
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
||||
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
||||
const feishuAcpConversationSupported =
|
||||
!isGroup ||
|
||||
groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender";
|
||||
|
||||
if (isGroup && groupSession) {
|
||||
log(
|
||||
@@ -1247,76 +1216,6 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const currentConversationId = peerId;
|
||||
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
|
||||
let configuredBinding = null;
|
||||
if (feishuAcpConversationSupported) {
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
cfg: effectiveCfg,
|
||||
route,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
configuredBinding = configuredRoute.configuredBinding;
|
||||
route = configuredRoute.route;
|
||||
|
||||
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
||||
// Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
|
||||
// configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
|
||||
const threadBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
});
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
if (threadBinding && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(threadBinding.bindingId);
|
||||
log(
|
||||
`feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg: effectiveCfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
const replyTargetMessageId =
|
||||
isGroup &&
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender")
|
||||
? (ctx.rootId ?? ctx.messageId)
|
||||
: ctx.messageId;
|
||||
await sendMessageFeishu({
|
||||
cfg: effectiveCfg,
|
||||
to: `chat:${ctx.chatId}`,
|
||||
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isGroup
|
||||
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
||||
|
||||
@@ -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;
|
||||
@@ -632,25 +631,19 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
||||
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
|
||||
}
|
||||
|
||||
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
|
||||
try {
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg });
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
registerEventHandlers(eventDispatcher, {
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
fireAndForget: true,
|
||||
});
|
||||
registerEventHandlers(eventDispatcher, {
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
fireAndForget: true,
|
||||
});
|
||||
|
||||
if (connectionMode === "webhook") {
|
||||
return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
} finally {
|
||||
threadBindingManager?.stop();
|
||||
if (connectionMode === "webhook") {
|
||||
return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -174,35 +174,6 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
|
||||
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
|
||||
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers word boundaries when html-limit retry splits formatted prose", () => {
|
||||
const input = "**Which of these**";
|
||||
const chunks = markdownToTelegramChunks(input, 16);
|
||||
expect(chunks.map((chunk) => chunk.text)).toEqual(["Which of ", "these"]);
|
||||
expect(chunks.every((chunk) => chunk.html.length <= 16)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to in-paren word boundaries when the parenthesis is unbalanced", () => {
|
||||
const input = "**foo (bar baz qux quux**";
|
||||
const chunks = markdownToTelegramChunks(input, 20);
|
||||
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]);
|
||||
expect(chunks.every((chunk) => chunk.html.length <= 20)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit whitespace-only chunks during html-limit retry splitting", () => {
|
||||
const input = "**ab <<**";
|
||||
const chunks = markdownToTelegramChunks(input, 11);
|
||||
expect(chunks.map((chunk) => chunk.text).join("")).toBe("ab <<");
|
||||
expect(chunks.every((chunk) => chunk.text.trim().length > 0)).toBe(true);
|
||||
expect(chunks.every((chunk) => chunk.html.length <= 11)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves paragraph separators when retry chunking produces whitespace-only spans", () => {
|
||||
const input = "ab\n\n<<";
|
||||
const chunks = markdownToTelegramChunks(input, 6);
|
||||
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
|
||||
expect(chunks.every((chunk) => chunk.html.length <= 6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
|
||||
@@ -141,7 +141,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
forceDocument,
|
||||
}) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
@@ -157,7 +156,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
baseOpts: {
|
||||
...baseOpts,
|
||||
mediaLocalRoots,
|
||||
forceDocument: forceDocument ?? false,
|
||||
},
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
|
||||
@@ -301,7 +301,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`,
|
||||
);
|
||||
}
|
||||
if (currentSettings.dmAllowlist !== undefined) {
|
||||
if (currentSettings.dmAllowlist?.length) {
|
||||
effectiveDmAllowlist = currentSettings.dmAllowlist;
|
||||
runtime.log?.(
|
||||
`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`,
|
||||
@@ -322,7 +322,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
`[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`,
|
||||
);
|
||||
}
|
||||
if (currentSettings.groupInviteAllowlist !== undefined) {
|
||||
if (currentSettings.groupInviteAllowlist?.length) {
|
||||
effectiveGroupInviteAllowlist = currentSettings.groupInviteAllowlist;
|
||||
runtime.log?.(
|
||||
`[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`,
|
||||
@@ -1176,14 +1176,17 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve any cited/quoted messages first
|
||||
const citedContent = await resolveAllCites(content.content);
|
||||
const rawText = extractMessageText(content.content);
|
||||
if (!rawText.trim()) {
|
||||
const messageText = citedContent + rawText;
|
||||
if (!messageText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
cacheMessage(nest, {
|
||||
author: senderShip,
|
||||
content: rawText,
|
||||
content: messageText,
|
||||
timestamp: content.sent || Date.now(),
|
||||
id: messageId,
|
||||
});
|
||||
@@ -1197,7 +1200,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
// Check if we should respond:
|
||||
// 1. Direct mention always triggers response
|
||||
// 2. Thread replies where we've participated - respond if relevant (let agent decide)
|
||||
const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined);
|
||||
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
|
||||
const inParticipatedThread =
|
||||
isThreadReply && parentId && participatedThreads.has(String(parentId));
|
||||
|
||||
@@ -1224,10 +1227,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
type: "channel",
|
||||
requestingShip: senderShip,
|
||||
channelNest: nest,
|
||||
messagePreview: rawText.substring(0, 100),
|
||||
messagePreview: messageText.substring(0, 100),
|
||||
originalMessage: {
|
||||
messageId: messageId ?? "",
|
||||
messageText: rawText,
|
||||
messageText,
|
||||
messageContent: content.content,
|
||||
timestamp: content.sent || Date.now(),
|
||||
parentId: parentId ?? undefined,
|
||||
@@ -1245,10 +1248,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve quoted content only after the sender passed channel authorization.
|
||||
const citedContent = await resolveAllCites(content.content);
|
||||
const messageText = citedContent + rawText;
|
||||
|
||||
const parsed = parseChannelNest(nest);
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
@@ -1366,15 +1365,15 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve any cited/quoted messages first
|
||||
const citedContent = await resolveAllCites(essay.content);
|
||||
const rawText = extractMessageText(essay.content);
|
||||
if (!rawText.trim()) {
|
||||
const messageText = citedContent + rawText;
|
||||
if (!messageText.trim()) {
|
||||
return;
|
||||
}
|
||||
const citedContent = await resolveAllCites(essay.content);
|
||||
const resolvedMessageText = citedContent + rawText;
|
||||
|
||||
// Check if this is the owner sending an approval response
|
||||
const messageText = rawText;
|
||||
if (isOwner(senderShip) && isApprovalResponse(messageText)) {
|
||||
const handled = await handleApprovalResponse(messageText);
|
||||
if (handled) {
|
||||
@@ -1398,7 +1397,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
senderShip,
|
||||
messageText: resolvedMessageText,
|
||||
messageText,
|
||||
messageContent: essay.content,
|
||||
isGroup: false,
|
||||
timestamp: essay.sent || Date.now(),
|
||||
@@ -1431,7 +1430,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
senderShip,
|
||||
messageText: resolvedMessageText,
|
||||
messageText,
|
||||
messageContent: essay.content, // Pass raw content for media extraction
|
||||
isGroup: false,
|
||||
timestamp: essay.sent || Date.now(),
|
||||
@@ -1525,7 +1524,8 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
|
||||
// Update DM allowlist
|
||||
if (newSettings.dmAllowlist !== undefined) {
|
||||
effectiveDmAllowlist = newSettings.dmAllowlist;
|
||||
effectiveDmAllowlist =
|
||||
newSettings.dmAllowlist.length > 0 ? newSettings.dmAllowlist : account.dmAllowlist;
|
||||
runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -1551,7 +1551,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
|
||||
// Update group invite allowlist
|
||||
if (newSettings.groupInviteAllowlist !== undefined) {
|
||||
effectiveGroupInviteAllowlist = newSettings.groupInviteAllowlist;
|
||||
effectiveGroupInviteAllowlist =
|
||||
newSettings.groupInviteAllowlist.length > 0
|
||||
? newSettings.groupInviteAllowlist
|
||||
: account.groupInviteAllowlist;
|
||||
runtime.log?.(
|
||||
`[tlon] Settings: groupInviteAllowlist updated to ${effectiveGroupInviteAllowlist.join(", ")}`,
|
||||
);
|
||||
|
||||
@@ -160,13 +160,6 @@ describe("checkTwitchAccessControl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks everyone when allowFrom is explicitly empty", () => {
|
||||
expectAllowFromBlocked({
|
||||
allowFrom: [],
|
||||
reason: "allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks messages without userId", () => {
|
||||
expectAllowFromBlocked({
|
||||
allowFrom: ["123456"],
|
||||
|
||||
@@ -48,14 +48,8 @@ export function checkTwitchAccessControl(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (account.allowFrom !== undefined) {
|
||||
if (account.allowFrom && account.allowFrom.length > 0) {
|
||||
const allowFrom = account.allowFrom;
|
||||
if (allowFrom.length === 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "sender is not in allowFrom allowlist",
|
||||
};
|
||||
}
|
||||
const senderId = message.userId;
|
||||
|
||||
if (!senderId) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
resolveWhatsAppGroupIntroHint,
|
||||
resolveWhatsAppGroupToolPolicy,
|
||||
resolveWhatsAppHeartbeatRecipients,
|
||||
resolveWhatsAppMentionStripRegexes,
|
||||
resolveWhatsAppMentionStripPatterns,
|
||||
WhatsAppConfigSchema,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
@@ -214,7 +214,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
|
||||
},
|
||||
mentions: {
|
||||
stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx),
|
||||
stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx),
|
||||
},
|
||||
commands: {
|
||||
enforceOwnerForCommands: true,
|
||||
|
||||
@@ -413,13 +413,7 @@ export async function monitorWebInbox(options: {
|
||||
|
||||
// If this is history/offline catch-up, mark read above but skip auto-reply.
|
||||
if (upsert.type === "append") {
|
||||
const APPEND_RECENT_GRACE_MS = 60_000;
|
||||
const msgTsRaw = msg.messageTimestamp;
|
||||
const msgTsNum = msgTsRaw != null ? Number(msgTsRaw) : NaN;
|
||||
const msgTsMs = Number.isFinite(msgTsNum) ? msgTsNum * 1000 : 0;
|
||||
if (msgTsMs < connectedAtMs - APPEND_RECENT_GRACE_MS) {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const enriched = await enrichInboundMessage(msg);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
logoutWeb,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js";
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
const createWaSocket = vi.fn(
|
||||
@@ -22,13 +17,11 @@ vi.mock("./session.js", () => {
|
||||
const getStatusCode = vi.fn(
|
||||
(err: unknown) =>
|
||||
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
|
||||
(err as { status?: number })?.status,
|
||||
);
|
||||
const webAuthExists = vi.fn(async () => false);
|
||||
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
|
||||
const logoutWeb = vi.fn(async () => true);
|
||||
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
|
||||
return {
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
@@ -37,7 +30,6 @@ vi.mock("./session.js", () => {
|
||||
webAuthExists,
|
||||
readWebSelfId,
|
||||
logoutWeb,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,43 +39,22 @@ vi.mock("./qr-image.js", () => ({
|
||||
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
|
||||
const logoutWebMock = vi.mocked(logoutWeb);
|
||||
|
||||
async function flushTasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("login-qr", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("restarts login once on status 515 and completes", async () => {
|
||||
let releaseCredsFlush: (() => void) | undefined;
|
||||
const credsFlushGate = new Promise<void>((resolve) => {
|
||||
releaseCredsFlush = resolve;
|
||||
});
|
||||
waitForWaConnectionMock
|
||||
// Baileys v7 wraps the error: { error: BoomError(515) }
|
||||
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
|
||||
.mockRejectedValueOnce({ output: { statusCode: 515 } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
|
||||
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
|
||||
|
||||
const resultPromise = waitForWebLogin({ timeoutMs: 5000 });
|
||||
await flushTasks();
|
||||
await flushTasks();
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
|
||||
|
||||
releaseCredsFlush?.();
|
||||
const result = await resultPromise;
|
||||
const result = await waitForWebLogin({ timeoutMs: 5000 });
|
||||
|
||||
expect(result.connected).toBe(true);
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getStatusCode,
|
||||
logoutWeb,
|
||||
readWebSelfId,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "./session.js";
|
||||
@@ -86,10 +85,9 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
|
||||
}
|
||||
login.restartAttempted = true;
|
||||
runtime.log(
|
||||
info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
|
||||
info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"),
|
||||
);
|
||||
closeSocket(login.sock);
|
||||
await waitForCredsSaveQueueWithTimeout(login.authDir);
|
||||
try {
|
||||
const sock = await createWaSocket(false, login.verbose, {
|
||||
authDir: login.authDir,
|
||||
|
||||
@@ -4,12 +4,7 @@ import path from "node:path";
|
||||
import { DisconnectReason } from "@whiskeysockets/baileys";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loginWeb } from "./login.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { createWaSocket, formatError, waitForWaConnection } from "./session.js";
|
||||
|
||||
const rmMock = vi.spyOn(fs, "rm");
|
||||
|
||||
@@ -40,19 +35,10 @@ vi.mock("./session.js", () => {
|
||||
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
|
||||
const waitForWaConnection = vi.fn();
|
||||
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
|
||||
const getStatusCode = vi.fn(
|
||||
(err: unknown) =>
|
||||
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
|
||||
);
|
||||
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
|
||||
return {
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
formatError,
|
||||
getStatusCode,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
WA_WEB_AUTH_DIR: authDir,
|
||||
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
|
||||
await fs.rm(params.authDir ?? authDir, {
|
||||
@@ -66,14 +52,8 @@ vi.mock("./session.js", () => {
|
||||
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
|
||||
const formatErrorMock = vi.mocked(formatError);
|
||||
|
||||
async function flushTasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("loginWeb coverage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -85,25 +65,12 @@ describe("loginWeb coverage", () => {
|
||||
});
|
||||
|
||||
it("restarts once when WhatsApp requests code 515", async () => {
|
||||
let releaseCredsFlush: (() => void) | undefined;
|
||||
const credsFlushGate = new Promise<void>((resolve) => {
|
||||
releaseCredsFlush = resolve;
|
||||
});
|
||||
waitForWaConnectionMock
|
||||
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
|
||||
.mockRejectedValueOnce({ output: { statusCode: 515 } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
|
||||
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as never;
|
||||
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
|
||||
await flushTasks();
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(authDir);
|
||||
|
||||
releaseCredsFlush?.();
|
||||
await pendingLogin;
|
||||
await loginWeb(false, waitForWaConnectionMock as never, runtime);
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
const firstSock = await createWaSocketMock.mock.results[0]?.value;
|
||||
|
||||
@@ -5,14 +5,7 @@ import { danger, info, success } from "../../../src/globals.js";
|
||||
import { logInfo } from "../../../src/logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { resolveWhatsAppAccount } from "./accounts.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
getStatusCode,
|
||||
logoutWeb,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js";
|
||||
|
||||
export async function loginWeb(
|
||||
verbose: boolean,
|
||||
@@ -31,17 +24,20 @@ export async function loginWeb(
|
||||
await wait(sock);
|
||||
console.log(success("✅ Linked! Credentials saved for future sends."));
|
||||
} catch (err) {
|
||||
const code = getStatusCode(err);
|
||||
const code =
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
|
||||
(err as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||
if (code === 515) {
|
||||
console.log(
|
||||
info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
|
||||
info(
|
||||
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
|
||||
),
|
||||
);
|
||||
try {
|
||||
sock.ws?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await waitForCredsSaveQueueWithTimeout(account.authDir);
|
||||
const retry = await createWaSocket(false, verbose, {
|
||||
authDir: account.authDir,
|
||||
});
|
||||
|
||||
@@ -254,7 +254,6 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("handles append messages by marking them read but skipping auto-reply", async () => {
|
||||
const { onMessage, listener, sock } = await openInboxMonitor();
|
||||
const staleTs = Math.floor(Date.now() / 1000) - 300;
|
||||
|
||||
const upsert = {
|
||||
type: "append",
|
||||
@@ -266,7 +265,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "old message" },
|
||||
messageTimestamp: staleTs,
|
||||
messageTimestamp: nowSeconds(),
|
||||
pushName: "History Sender",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import "./monitor-inbox.test-harness.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { monitorWebInbox } from "./inbound.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAuthDir,
|
||||
getSock,
|
||||
installWebMonitorInboxUnitTestHooks,
|
||||
} from "./monitor-inbox.test-harness.js";
|
||||
|
||||
describe("append upsert handling (#20952)", () => {
|
||||
installWebMonitorInboxUnitTestHooks();
|
||||
type InboxOnMessage = NonNullable<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
|
||||
|
||||
async function tick() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function startInboxMonitor(onMessage: InboxOnMessage) {
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
authDir: getAuthDir(),
|
||||
});
|
||||
return { listener, sock: getSock() };
|
||||
}
|
||||
|
||||
it("processes recent append messages (within 60s of connect)", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const { listener, sock } = await startInboxMonitor(onMessage);
|
||||
|
||||
// Timestamp ~5 seconds ago — recent, should be processed.
|
||||
const recentTs = Math.floor(Date.now() / 1000) - 5;
|
||||
sock.ev.emit("messages.upsert", {
|
||||
type: "append",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" },
|
||||
message: { conversation: "hello from group" },
|
||||
messageTimestamp: recentTs,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("skips stale append messages (older than 60s before connect)", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const { listener, sock } = await startInboxMonitor(onMessage);
|
||||
|
||||
// Timestamp 5 minutes ago — stale history sync, should be skipped.
|
||||
const staleTs = Math.floor(Date.now() / 1000) - 300;
|
||||
sock.ev.emit("messages.upsert", {
|
||||
type: "append",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" },
|
||||
message: { conversation: "old history sync" },
|
||||
messageTimestamp: staleTs,
|
||||
pushName: "OldTester",
|
||||
},
|
||||
],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("skips append messages with NaN/non-finite timestamps", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const { listener, sock } = await startInboxMonitor(onMessage);
|
||||
|
||||
// NaN timestamp should be treated as 0 (stale) and skipped.
|
||||
sock.ev.emit("messages.upsert", {
|
||||
type: "append",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" },
|
||||
message: { conversation: "bad timestamp" },
|
||||
messageTimestamp: NaN,
|
||||
pushName: "BadTs",
|
||||
},
|
||||
],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("handles Long-like protobuf timestamps correctly", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const { listener, sock } = await startInboxMonitor(onMessage);
|
||||
|
||||
// Baileys can deliver messageTimestamp as a Long object (from protobufjs).
|
||||
// Number(longObj) calls valueOf() and returns the numeric value.
|
||||
const recentTs = Math.floor(Date.now() / 1000) - 5;
|
||||
const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs };
|
||||
sock.ev.emit("messages.upsert", {
|
||||
type: "append",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" },
|
||||
message: { conversation: "long timestamp" },
|
||||
messageTimestamp: longLike,
|
||||
pushName: "LongTs",
|
||||
},
|
||||
],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("always processes notify messages regardless of timestamp", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const { listener, sock } = await startInboxMonitor(onMessage);
|
||||
|
||||
// Very old timestamp but type=notify — should always be processed.
|
||||
const oldTs = Math.floor(Date.now() / 1000) - 86400;
|
||||
sock.ev.emit("messages.upsert", {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "normal message" },
|
||||
messageTimestamp: oldTs,
|
||||
pushName: "User",
|
||||
},
|
||||
],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
@@ -204,62 +204,6 @@ describe("web session", () => {
|
||||
expect(inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it("lets different authDir queues flush independently", async () => {
|
||||
let inFlightA = 0;
|
||||
let inFlightB = 0;
|
||||
let releaseA: (() => void) | null = null;
|
||||
let releaseB: (() => void) | null = null;
|
||||
const gateA = new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
const gateB = new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
|
||||
const saveCredsA = vi.fn(async () => {
|
||||
inFlightA += 1;
|
||||
await gateA;
|
||||
inFlightA -= 1;
|
||||
});
|
||||
const saveCredsB = vi.fn(async () => {
|
||||
inFlightB += 1;
|
||||
await gateB;
|
||||
inFlightB -= 1;
|
||||
});
|
||||
useMultiFileAuthStateMock
|
||||
.mockResolvedValueOnce({
|
||||
state: { creds: {} as never, keys: {} as never },
|
||||
saveCreds: saveCredsA,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: { creds: {} as never, keys: {} as never },
|
||||
saveCreds: saveCredsB,
|
||||
});
|
||||
|
||||
await createWaSocket(false, false, { authDir: "/tmp/wa-a" });
|
||||
const sockA = getLastSocket();
|
||||
await createWaSocket(false, false, { authDir: "/tmp/wa-b" });
|
||||
const sockB = getLastSocket();
|
||||
|
||||
sockA.ev.emit("creds.update", {});
|
||||
sockB.ev.emit("creds.update", {});
|
||||
|
||||
await flushCredsUpdate();
|
||||
|
||||
expect(saveCredsA).toHaveBeenCalledTimes(1);
|
||||
expect(saveCredsB).toHaveBeenCalledTimes(1);
|
||||
expect(inFlightA).toBe(1);
|
||||
expect(inFlightB).toBe(1);
|
||||
|
||||
(releaseA as (() => void) | null)?.();
|
||||
(releaseB as (() => void) | null)?.();
|
||||
await flushCredsUpdate();
|
||||
await flushCredsUpdate();
|
||||
|
||||
expect(inFlightA).toBe(0);
|
||||
expect(inFlightB).toBe(0);
|
||||
});
|
||||
|
||||
it("rotates creds backup when creds.json is valid JSON", async () => {
|
||||
const creds = mockCredsJsonSpies("{}");
|
||||
const backupSuffix = path.join(
|
||||
|
||||
@@ -31,24 +31,17 @@ export {
|
||||
webAuthExists,
|
||||
} from "./auth-store.js";
|
||||
|
||||
// Per-authDir queues so multi-account creds saves don't block each other.
|
||||
const credsSaveQueues = new Map<string, Promise<void>>();
|
||||
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
|
||||
let credsSaveQueue: Promise<void> = Promise.resolve();
|
||||
function enqueueSaveCreds(
|
||||
authDir: string,
|
||||
saveCreds: () => Promise<void> | void,
|
||||
logger: ReturnType<typeof getChildLogger>,
|
||||
): void {
|
||||
const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
|
||||
const next = prev
|
||||
credsSaveQueue = credsSaveQueue
|
||||
.then(() => safeSaveCreds(authDir, saveCreds, logger))
|
||||
.catch((err) => {
|
||||
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (credsSaveQueues.get(authDir) === next) credsSaveQueues.delete(authDir);
|
||||
});
|
||||
credsSaveQueues.set(authDir, next);
|
||||
}
|
||||
|
||||
async function safeSaveCreds(
|
||||
@@ -193,37 +186,10 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
|
||||
export function getStatusCode(err: unknown) {
|
||||
return (
|
||||
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode
|
||||
(err as { status?: number })?.status
|
||||
);
|
||||
}
|
||||
|
||||
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
|
||||
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
|
||||
if (authDir) {
|
||||
return credsSaveQueues.get(authDir) ?? Promise.resolve();
|
||||
}
|
||||
return Promise.all(credsSaveQueues.values()).then(() => {});
|
||||
}
|
||||
|
||||
/** Await pending credential saves, but don't hang forever on stalled I/O. */
|
||||
export async function waitForCredsSaveQueueWithTimeout(
|
||||
authDir: string,
|
||||
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
waitForCredsSaveQueue(authDir),
|
||||
new Promise<void>((resolve) => {
|
||||
flushTimeout = setTimeout(resolve, timeoutMs);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown, limit = 800): string {
|
||||
try {
|
||||
const seen = new WeakSet();
|
||||
|
||||
@@ -4,20 +4,8 @@ export function runWatchMain(params?: {
|
||||
args: string[],
|
||||
options: unknown,
|
||||
) => {
|
||||
kill?: (signal?: NodeJS.Signals | number) => void;
|
||||
on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void;
|
||||
};
|
||||
createWatcher?: (
|
||||
paths: string[],
|
||||
options: {
|
||||
ignoreInitial: boolean;
|
||||
ignored: (watchPath: string) => boolean;
|
||||
},
|
||||
) => {
|
||||
on: (event: "add" | "change" | "unlink" | "error", cb: (arg?: unknown) => void) => void;
|
||||
close?: () => Promise<void> | void;
|
||||
};
|
||||
watchPaths?: string[];
|
||||
process?: NodeJS.Process;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
|
||||
@@ -2,24 +2,16 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import chokidar from "chokidar";
|
||||
import { runNodeWatchedPaths } from "./run-node.mjs";
|
||||
|
||||
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
|
||||
const WATCH_RESTART_SIGNAL = "SIGTERM";
|
||||
|
||||
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
|
||||
|
||||
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
|
||||
|
||||
const isIgnoredWatchPath = (filePath) => {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
return (
|
||||
normalizedPath.endsWith(".test.ts") ||
|
||||
normalizedPath.endsWith(".test.tsx") ||
|
||||
normalizedPath.endsWith("test-helpers.ts")
|
||||
);
|
||||
};
|
||||
const buildWatchArgs = (args) => [
|
||||
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
|
||||
"--watch-preserve-output",
|
||||
WATCH_NODE_RUNNER,
|
||||
...args,
|
||||
];
|
||||
|
||||
export async function runWatchMain(params = {}) {
|
||||
const deps = {
|
||||
@@ -29,9 +21,6 @@ export async function runWatchMain(params = {}) {
|
||||
args: params.args ?? process.argv.slice(2),
|
||||
env: params.env ? { ...params.env } : { ...process.env },
|
||||
now: params.now ?? Date.now,
|
||||
createWatcher:
|
||||
params.createWatcher ?? ((watchPaths, options) => chokidar.watch(watchPaths, options)),
|
||||
watchPaths: params.watchPaths ?? runNodeWatchedPaths,
|
||||
};
|
||||
|
||||
const childEnv = { ...deps.env };
|
||||
@@ -42,96 +31,54 @@ export async function runWatchMain(params = {}) {
|
||||
childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" ");
|
||||
}
|
||||
|
||||
const watchProcess = deps.spawn(deps.process.execPath, buildWatchArgs(deps.args), {
|
||||
cwd: deps.cwd,
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let onSigInt;
|
||||
let onSigTerm;
|
||||
|
||||
const settle = (resolve, code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
if (onSigTerm) {
|
||||
deps.process.off("SIGTERM", onSigTerm);
|
||||
}
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let shuttingDown = false;
|
||||
let restartRequested = false;
|
||||
let watchProcess = null;
|
||||
let onSigInt;
|
||||
let onSigTerm;
|
||||
|
||||
const watcher = deps.createWatcher(deps.watchPaths, {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath) => isIgnoredWatchPath(watchPath),
|
||||
});
|
||||
|
||||
const settle = (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
if (onSigTerm) {
|
||||
deps.process.off("SIGTERM", onSigTerm);
|
||||
}
|
||||
watcher.close?.().catch?.(() => {});
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const startRunner = () => {
|
||||
watchProcess = deps.spawn(deps.process.execPath, buildRunnerArgs(deps.args), {
|
||||
cwd: deps.cwd,
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
watchProcess.on("exit", () => {
|
||||
watchProcess = null;
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (restartRequested) {
|
||||
restartRequested = false;
|
||||
startRunner();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const requestRestart = (changedPath) => {
|
||||
if (shuttingDown || isIgnoredWatchPath(changedPath)) {
|
||||
return;
|
||||
}
|
||||
if (!watchProcess) {
|
||||
startRunner();
|
||||
return;
|
||||
}
|
||||
restartRequested = true;
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
};
|
||||
|
||||
watcher.on("add", requestRestart);
|
||||
watcher.on("change", requestRestart);
|
||||
watcher.on("unlink", requestRestart);
|
||||
watcher.on("error", () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(1);
|
||||
});
|
||||
|
||||
startRunner();
|
||||
|
||||
onSigInt = () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGTERM");
|
||||
}
|
||||
settle(130);
|
||||
settle(resolve, 130);
|
||||
};
|
||||
onSigTerm = () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGTERM");
|
||||
}
|
||||
settle(143);
|
||||
settle(resolve, 143);
|
||||
};
|
||||
|
||||
deps.process.on("SIGINT", onSigInt);
|
||||
deps.process.on("SIGTERM", onSigTerm);
|
||||
|
||||
watchProcess.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
settle(resolve, 1);
|
||||
return;
|
||||
}
|
||||
settle(resolve, code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ Use `remindctl` to manage Apple Reminders directly from the terminal.
|
||||
|
||||
❌ **DON'T use this skill when:**
|
||||
|
||||
- Scheduling OpenClaw tasks or alerts → use `cron` tool with systemEvent instead
|
||||
- Scheduling Clawdbot tasks or alerts → use `cron` tool with systemEvent instead
|
||||
- Calendar events or appointments → use Apple Calendar
|
||||
- Project/work task management → use Notion, GitHub Issues, or task queue
|
||||
- One-time notifications → use `cron` tool for timed alerts
|
||||
- User says "remind me" but means an OpenClaw alert → clarify first
|
||||
- User says "remind me" but means a Clawdbot alert → clarify first
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -112,7 +112,7 @@ Accepted by `--due` and date filters:
|
||||
|
||||
User: "Remind me to check on the deploy in 2 hours"
|
||||
|
||||
**Ask:** "Do you want this in Apple Reminders (syncs to your phone) or as an OpenClaw alert (I'll message you here)?"
|
||||
**Ask:** "Do you want this in Apple Reminders (syncs to your phone) or as a Clawdbot alert (I'll message you here)?"
|
||||
|
||||
- Apple Reminders → use this skill
|
||||
- OpenClaw alert → use `cron` tool with systemEvent
|
||||
- Clawdbot alert → use `cron` tool with systemEvent
|
||||
|
||||
@@ -47,7 +47,7 @@ Use `imsg` to read and send iMessage/SMS via macOS Messages.app.
|
||||
- Slack messages → use `slack` skill
|
||||
- Group chat management (adding/removing members) → not supported
|
||||
- Bulk/mass messaging → always confirm with user first
|
||||
- Replying in current conversation → just reply normally (OpenClaw routes automatically)
|
||||
- Replying in current conversation → just reply normally (Clawdbot routes automatically)
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -366,47 +366,6 @@ describe("resolvePermissionRequest", () => {
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-approves safe tools when rawInput is the only identity hint", async () => {
|
||||
const prompt = vi.fn(async () => true);
|
||||
const res = await resolvePermissionRequest(
|
||||
makePermissionRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tool-raw-only",
|
||||
title: "Searching files",
|
||||
status: "pending",
|
||||
rawInput: {
|
||||
name: "search",
|
||||
query: "foo",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ prompt, log: () => {} },
|
||||
);
|
||||
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts when raw input spoofs a safe tool name for a dangerous title", async () => {
|
||||
const prompt = vi.fn(async () => false);
|
||||
const res = await resolvePermissionRequest(
|
||||
makePermissionRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tool-exec-spoof",
|
||||
title: "exec: cat /etc/passwd",
|
||||
status: "pending",
|
||||
rawInput: {
|
||||
command: "cat /etc/passwd",
|
||||
name: "search",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ prompt, log: () => {} },
|
||||
);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledWith(undefined, "exec: cat /etc/passwd");
|
||||
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
|
||||
});
|
||||
|
||||
it("prompts for read outside cwd scope", async () => {
|
||||
const prompt = vi.fn(async () => false);
|
||||
const res = await resolvePermissionRequest(
|
||||
|
||||
@@ -104,22 +104,7 @@ function resolveToolNameForPermission(params: RequestPermissionRequest): string
|
||||
const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
|
||||
const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
|
||||
const fromTitle = parseToolNameFromTitle(toolCall?.title);
|
||||
const metaName = fromMeta ? normalizeToolName(fromMeta) : undefined;
|
||||
const rawInputName = fromRawInput ? normalizeToolName(fromRawInput) : undefined;
|
||||
const titleName = fromTitle;
|
||||
if ((fromMeta && !metaName) || (fromRawInput && !rawInputName)) {
|
||||
return undefined;
|
||||
}
|
||||
if (metaName && titleName && metaName !== titleName) {
|
||||
return undefined;
|
||||
}
|
||||
if (rawInputName && metaName && rawInputName !== metaName) {
|
||||
return undefined;
|
||||
}
|
||||
if (rawInputName && titleName && rawInputName !== titleName) {
|
||||
return undefined;
|
||||
}
|
||||
return metaName ?? titleName ?? rawInputName;
|
||||
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
|
||||
}
|
||||
|
||||
function extractPathFromToolTitle(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js";
|
||||
import { listAcpBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentAcpBinding } from "../config/types.js";
|
||||
@@ -22,23 +21,12 @@ import {
|
||||
|
||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") {
|
||||
if (normalized === "discord" || normalized === "telegram") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
|
||||
const trimmed = conversationId.trim();
|
||||
if (!trimmed || trimmed.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||
const trimmed = (match ?? "").trim();
|
||||
if (!trimmed) {
|
||||
@@ -134,23 +122,14 @@ function resolveConfiguredBindingRecord(params: {
|
||||
bindings: AgentAcpBinding[];
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
selectConversation: (binding: AgentAcpBinding) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority?: number;
|
||||
} | null;
|
||||
selectConversation: (
|
||||
binding: AgentAcpBinding,
|
||||
) => { conversationId: string; parentConversationId?: string } | null;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
let wildcardMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority: number;
|
||||
} | null = null;
|
||||
let exactMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority: number;
|
||||
} | null = null;
|
||||
for (const binding of params.bindings) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
|
||||
@@ -167,40 +146,23 @@ function resolveConfiguredBindingRecord(params: {
|
||||
if (!conversation) {
|
||||
continue;
|
||||
}
|
||||
const matchPriority = conversation.matchPriority ?? 0;
|
||||
if (accountMatchPriority === 2) {
|
||||
if (!exactMatch || matchPriority > exactMatch.matchPriority) {
|
||||
exactMatch = {
|
||||
binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
matchPriority,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) {
|
||||
wildcardMatch = {
|
||||
binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
matchPriority,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (exactMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: exactMatch.conversationId,
|
||||
parentConversationId: exactMatch.parentConversationId,
|
||||
binding: exactMatch.binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
if (accountMatchPriority === 2) {
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = { binding, ...conversation };
|
||||
}
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
return null;
|
||||
@@ -266,42 +228,6 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (channel === "feishu") {
|
||||
const targetParsed = parseFeishuConversationId({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (
|
||||
!targetParsed ||
|
||||
(targetParsed.scope !== "group_topic" &&
|
||||
targetParsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "feishu",
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: targetParsed.canonicalConversationId,
|
||||
// Session-key recovery deliberately collapses sender-scoped topic bindings onto the
|
||||
// canonical topic conversation id so `group_topic` and `group_topic_sender` reuse
|
||||
// the same configured ACP session identity.
|
||||
parentConversationId:
|
||||
targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender"
|
||||
? targetParsed.chatId
|
||||
: undefined,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const parsedTopic = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
@@ -408,63 +334,5 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (channel === "feishu") {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
if (
|
||||
!parsed ||
|
||||
(parsed.scope !== "group_topic" &&
|
||||
parsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
const targetParsed = parseFeishuConversationId({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (
|
||||
!targetParsed ||
|
||||
(targetParsed.scope !== "group_topic" &&
|
||||
targetParsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const matchesCanonicalConversation =
|
||||
targetParsed.canonicalConversationId === parsed.canonicalConversationId;
|
||||
const matchesParentTopicForSenderScopedConversation =
|
||||
parsed.scope === "group_topic_sender" &&
|
||||
targetParsed.scope === "group_topic" &&
|
||||
parsed.chatId === targetParsed.chatId &&
|
||||
parsed.topicId === targetParsed.topicId;
|
||||
if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: matchesParentTopicForSenderScopedConversation
|
||||
? targetParsed.canonicalConversationId
|
||||
: parsed.canonicalConversationId,
|
||||
parentConversationId:
|
||||
parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
|
||||
? parsed.chatId
|
||||
: undefined,
|
||||
matchPriority: matchesCanonicalConversation ? 2 : 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,27 +90,6 @@ function createTelegramGroupBinding(params: {
|
||||
} as ConfiguredBinding;
|
||||
}
|
||||
|
||||
function createFeishuBinding(params: {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
accountId?: string;
|
||||
acp?: Record<string, unknown>;
|
||||
}): ConfiguredBinding {
|
||||
return {
|
||||
type: "acp",
|
||||
agentId: params.agentId,
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId ?? defaultDiscordAccountId,
|
||||
peer: {
|
||||
kind: params.conversationId.includes(":topic:") ? "group" : "direct",
|
||||
id: params.conversationId,
|
||||
},
|
||||
},
|
||||
...(params.acp ? { acp: params.acp } : {}),
|
||||
} as ConfiguredBinding;
|
||||
}
|
||||
|
||||
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
||||
return resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
@@ -226,34 +205,6 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("prefers sender-scoped Feishu bindings over topic inheritance", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
accountId: "work",
|
||||
}),
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
accountId: "work",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe(
|
||||
"oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
);
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("prefers exact account binding over wildcard for the same discord conversation", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
@@ -333,128 +284,6 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves Feishu DM bindings using direct peer ids", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "ou_user_1",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_user_1",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.channel).toBe("feishu");
|
||||
expect(resolved?.spec.conversationId).toBe("ou_user_1");
|
||||
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
|
||||
});
|
||||
|
||||
it("resolves Feishu DM bindings using user_id fallback peer ids", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "user_123",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "user_123",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.channel).toBe("feishu");
|
||||
expect(resolved?.spec.conversationId).toBe("user_123");
|
||||
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
|
||||
});
|
||||
|
||||
it("resolves Feishu topic bindings with parent chat ids", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
expect(resolved?.record.conversation.parentConversationId).toBe("oc_group_chat");
|
||||
});
|
||||
|
||||
it("inherits configured Feishu topic bindings for sender-scoped topic conversations", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
expect(resolved?.spec.backend).toBe("acpx");
|
||||
expect(resolved?.record.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
|
||||
it("rejects non-matching Feishu topic roots", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_other_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects Feishu non-topic group ACP bindings", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("applies agent runtime ACP defaults for bound conversations", () => {
|
||||
const cfg = createCfgWithBindings(
|
||||
[
|
||||
@@ -536,31 +365,6 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
|
||||
expect(spec?.backend).toBe("exact");
|
||||
});
|
||||
|
||||
it("maps a configured Feishu user_id DM binding session key back to its spec", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "user_123",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "user_123",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
|
||||
expect(spec?.channel).toBe("feishu");
|
||||
expect(spec?.conversationId).toBe("user_123");
|
||||
expect(spec?.agentId).toBe("codex");
|
||||
expect(spec?.backend).toBe("acpx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfiguredAcpSessionKey", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu";
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "telegram";
|
||||
|
||||
export type ConfiguredAcpBindingSpec = {
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPatch } from "./apply-patch.js";
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
|
||||
@@ -147,25 +147,6 @@ describe("applyPatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves delete targets before calling fs.rm", async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const target = path.join(dir, "delete-me.txt");
|
||||
await fs.writeFile(target, "x\n", "utf8");
|
||||
const rmSpy = vi.spyOn(fs, "rm");
|
||||
|
||||
try {
|
||||
const patch = `*** Begin Patch
|
||||
*** Delete File: delete-me.txt
|
||||
*** End Patch`;
|
||||
|
||||
await applyPatch(patch, { cwd: dir });
|
||||
expect(rmSpy).toHaveBeenCalledWith(target);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects symlink escape attempts by default", async () => {
|
||||
// File symlinks require SeCreateSymbolicLinkPrivilege on Windows.
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -270,28 +270,8 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps {
|
||||
encoding: "utf8",
|
||||
});
|
||||
},
|
||||
remove: async (filePath) => {
|
||||
if (workspaceOnly) {
|
||||
await assertSandboxPath({
|
||||
filePath,
|
||||
cwd: options.cwd,
|
||||
root: options.cwd,
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
allowFinalHardlinkForUnlink: true,
|
||||
});
|
||||
}
|
||||
await fs.rm(filePath);
|
||||
},
|
||||
mkdirp: async (dir) => {
|
||||
if (workspaceOnly) {
|
||||
await assertSandboxPath({
|
||||
filePath: dir,
|
||||
cwd: options.cwd,
|
||||
root: options.cwd,
|
||||
});
|
||||
}
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
},
|
||||
remove: (filePath) => fs.rm(filePath),
|
||||
mkdirp: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,20 +90,6 @@ describe("lookupContextTokens", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("skips eager warmup for logs commands that do not need model metadata at startup", async () => {
|
||||
const loadConfigMock = vi.fn(() => ({ models: {} }));
|
||||
mockContextModuleDeps(loadConfigMock);
|
||||
|
||||
const argvSnapshot = process.argv;
|
||||
process.argv = ["node", "openclaw", "logs", "--limit", "5"];
|
||||
try {
|
||||
await import("./context.js");
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.argv = argvSnapshot;
|
||||
}
|
||||
});
|
||||
|
||||
it("retries config loading after backoff when an initial load fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const loadConfigMock = vi
|
||||
|
||||
@@ -108,24 +108,9 @@ function getCommandPathFromArgv(argv: string[]): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([
|
||||
"backup",
|
||||
"completion",
|
||||
"config",
|
||||
"directory",
|
||||
"doctor",
|
||||
"health",
|
||||
"hooks",
|
||||
"logs",
|
||||
"plugins",
|
||||
"secrets",
|
||||
"update",
|
||||
"webhooks",
|
||||
]);
|
||||
|
||||
function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): boolean {
|
||||
const [primary] = getCommandPathFromArgv(argv);
|
||||
return primary ? SKIP_EAGER_WARMUP_PRIMARY_COMMANDS.has(primary) : false;
|
||||
const [primary, secondary] = getCommandPathFromArgv(argv);
|
||||
return primary === "config" && secondary === "validate";
|
||||
}
|
||||
|
||||
function primeConfiguredContextWindows(): OpenClawConfig | undefined {
|
||||
|
||||
@@ -28,10 +28,6 @@ function supportsUsageInStreaming(model: Model<Api>): boolean | undefined {
|
||||
?.supportsUsageInStreaming;
|
||||
}
|
||||
|
||||
function supportsStrictMode(model: Model<Api>): boolean | undefined {
|
||||
return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode;
|
||||
}
|
||||
|
||||
function createTemplateModel(provider: string, id: string): Model<Api> {
|
||||
return {
|
||||
id,
|
||||
@@ -98,13 +94,6 @@ function expectSupportsUsageInStreamingForcedOff(overrides?: Partial<Model<Api>>
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
}
|
||||
|
||||
function expectSupportsStrictModeForcedOff(overrides?: Partial<Model<Api>>): void {
|
||||
const model = { ...baseModel(), ...overrides };
|
||||
delete (model as { compat?: unknown }).compat;
|
||||
const normalized = normalizeModelCompat(model as Model<Api>);
|
||||
expect(supportsStrictMode(normalized)).toBe(false);
|
||||
}
|
||||
|
||||
function expectResolvedForwardCompat(
|
||||
model: Model<Api> | undefined,
|
||||
expected: { provider: string; id: string },
|
||||
@@ -237,17 +226,6 @@ describe("normalizeModelCompat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forces supportsStrictMode off for z.ai models", () => {
|
||||
expectSupportsStrictModeForcedOff();
|
||||
});
|
||||
|
||||
it("forces supportsStrictMode off for custom openai-completions provider", () => {
|
||||
expectSupportsStrictModeForcedOff({
|
||||
provider: "custom-cpa",
|
||||
baseUrl: "https://cpa.example.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
|
||||
expectSupportsDeveloperRoleForcedOff({
|
||||
provider: "qwen-proxy",
|
||||
@@ -305,18 +283,6 @@ describe("normalizeModelCompat", () => {
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
expect(supportsStrictMode(normalized)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects explicit supportsStrictMode true on non-native endpoints", () => {
|
||||
const model = {
|
||||
...baseModel(),
|
||||
provider: "custom-cpa",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
compat: { supportsStrictMode: true },
|
||||
};
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(supportsStrictMode(normalized)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
|
||||
@@ -330,23 +296,16 @@ describe("normalizeModelCompat", () => {
|
||||
expect(normalized).not.toBe(model);
|
||||
expect(supportsDeveloperRole(model)).toBeUndefined();
|
||||
expect(supportsUsageInStreaming(model)).toBeUndefined();
|
||||
expect(supportsStrictMode(model)).toBeUndefined();
|
||||
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
expect(supportsStrictMode(normalized)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not override explicit compat false", () => {
|
||||
const model = baseModel();
|
||||
model.compat = {
|
||||
supportsDeveloperRole: false,
|
||||
supportsUsageInStreaming: false,
|
||||
supportsStrictMode: false,
|
||||
};
|
||||
model.compat = { supportsDeveloperRole: false, supportsUsageInStreaming: false };
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
expect(supportsStrictMode(normalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,10 +54,9 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
|
||||
// The `developer` role and stream usage chunks are OpenAI-native behaviors.
|
||||
// Many OpenAI-compatible backends reject `developer` and/or emit usage-only
|
||||
// chunks that break strict parsers expecting choices[0]. Additionally, the
|
||||
// `strict` boolean inside tools validation is rejected by several providers
|
||||
// causing tool calls to be ignored. For non-native openai-completions endpoints,
|
||||
// default these compat flags off unless explicitly opted in.
|
||||
// chunks that break strict parsers expecting choices[0]. For non-native
|
||||
// openai-completions endpoints, force both compat flags off — unless the
|
||||
// user has explicitly opted in via their model config.
|
||||
const compat = model.compat ?? undefined;
|
||||
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so
|
||||
// leave compat unchanged and let default native behavior apply.
|
||||
@@ -65,14 +64,13 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
if (!needsForce) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// Respect explicit user overrides: if the user has set a compat flag to
|
||||
// true in their model definition, they know their endpoint supports it.
|
||||
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
|
||||
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
|
||||
const targetStrictMode = compat?.supportsStrictMode ?? false;
|
||||
if (
|
||||
compat?.supportsDeveloperRole !== undefined &&
|
||||
compat?.supportsUsageInStreaming !== undefined &&
|
||||
compat?.supportsStrictMode !== undefined
|
||||
) {
|
||||
|
||||
if (forcedDeveloperRole && forcedUsageStreaming) {
|
||||
return model;
|
||||
}
|
||||
|
||||
@@ -84,12 +82,7 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
...compat,
|
||||
supportsDeveloperRole: forcedDeveloperRole || false,
|
||||
supportsUsageInStreaming: forcedUsageStreaming || false,
|
||||
supportsStrictMode: targetStrictMode,
|
||||
}
|
||||
: {
|
||||
supportsDeveloperRole: false,
|
||||
supportsUsageInStreaming: false,
|
||||
supportsStrictMode: false,
|
||||
},
|
||||
: { supportsDeveloperRole: false, supportsUsageInStreaming: false },
|
||||
} as typeof model;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Keep model ID normalization dependency-free so config parsing and other
|
||||
// startup-only paths do not pull in provider discovery or plugin loading.
|
||||
export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") {
|
||||
return "gemini-3-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3-flash") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-pro") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-flash-lite") {
|
||||
return "gemini-3.1-flash-lite-preview";
|
||||
}
|
||||
// Preserve compatibility with earlier OpenClaw docs/config that pointed at a
|
||||
// non-existent Gemini Flash preview ID. Google's current Flash text model is
|
||||
// `gemini-3-flash-preview`.
|
||||
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "./agent-scope.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.js";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
|
||||
import { normalizeGoogleModelId } from "./models-config.providers.js";
|
||||
|
||||
const log = createSubsystemLogger("model-selection");
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import {
|
||||
normalizeAntigravityModelId,
|
||||
normalizeGoogleModelId,
|
||||
normalizeProviders,
|
||||
type ProviderConfig,
|
||||
} from "./models-config.providers.js";
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
buildCloudflareAiGatewayModelDefinition,
|
||||
resolveCloudflareAiGatewayBaseUrl,
|
||||
} from "./cloudflare-ai-gateway.js";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import {
|
||||
buildHuggingfaceProvider,
|
||||
buildKilocodeProviderWithDiscovery,
|
||||
@@ -71,7 +70,6 @@ import {
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
|
||||
export { normalizeGoogleModelId };
|
||||
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
@@ -225,6 +223,28 @@ function resolveApiKeyFromProfiles(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") {
|
||||
return "gemini-3-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3-flash") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-pro") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-flash-lite") {
|
||||
return "gemini-3.1-flash-lite-preview";
|
||||
}
|
||||
// Preserve compatibility with earlier OpenClaw docs/config that pointed at a
|
||||
// non-existent Gemini Flash preview ID. Google's current Flash text model is
|
||||
// `gemini-3-flash-preview`.
|
||||
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
|
||||
|
||||
export function normalizeAntigravityModelId(id: string): string {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
compactWithSafetyTimeout,
|
||||
EMBEDDED_COMPACTION_TIMEOUT_MS,
|
||||
resolveCompactionTimeoutMs,
|
||||
} from "./pi-embedded-runner/compaction-safety-timeout.js";
|
||||
|
||||
describe("compactWithSafetyTimeout", () => {
|
||||
@@ -43,113 +42,4 @@ describe("compactWithSafetyTimeout", () => {
|
||||
).rejects.toBe(error);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onCancel when compaction times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
|
||||
onCancel,
|
||||
});
|
||||
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await timeoutAssertion;
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("aborts early on external abort signal and calls onCancel once", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const onCancel = vi.fn();
|
||||
const reason = new Error("request timed out");
|
||||
|
||||
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 100, {
|
||||
abortSignal: controller.signal,
|
||||
onCancel,
|
||||
});
|
||||
const abortAssertion = expect(compactPromise).rejects.toBe(reason);
|
||||
|
||||
controller.abort(reason);
|
||||
await abortAssertion;
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores onCancel errors and still rejects with the timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
|
||||
onCancel: () => {
|
||||
throw new Error("abortCompaction failed");
|
||||
},
|
||||
});
|
||||
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await timeoutAssertion;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCompactionTimeoutMs", () => {
|
||||
it("returns default when config is undefined", () => {
|
||||
expect(resolveCompactionTimeoutMs(undefined)).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns default when compaction config is missing", () => {
|
||||
expect(resolveCompactionTimeoutMs({ agents: { defaults: {} } })).toBe(
|
||||
EMBEDDED_COMPACTION_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns default when timeoutSeconds is not set", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { mode: "safeguard" } } } }),
|
||||
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("converts timeoutSeconds to milliseconds", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({
|
||||
agents: { defaults: { compaction: { timeoutSeconds: 1800 } } },
|
||||
}),
|
||||
).toBe(1_800_000);
|
||||
});
|
||||
|
||||
it("floors fractional seconds", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({
|
||||
agents: { defaults: { compaction: { timeoutSeconds: 120.7 } } },
|
||||
}),
|
||||
).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns default for zero", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: 0 } } } }),
|
||||
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns default for negative values", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: -5 } } } }),
|
||||
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns default for NaN", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({
|
||||
agents: { defaults: { compaction: { timeoutSeconds: NaN } } },
|
||||
}),
|
||||
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns default for Infinity", () => {
|
||||
expect(
|
||||
resolveCompactionTimeoutMs({
|
||||
agents: { defaults: { compaction: { timeoutSeconds: Infinity } } },
|
||||
}),
|
||||
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectOpenAIResponsesStrictSanitizeCall,
|
||||
loadSanitizeSessionHistoryWithCleanMocks,
|
||||
makeMockSessionManager,
|
||||
makeInMemorySessionManager,
|
||||
@@ -248,24 +247,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(result).toEqual(mockMessages);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for OpenAI-compatible responses providers", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "custom",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
expectOpenAIResponsesStrictSanitizeCall(
|
||||
mockedHelpers.sanitizeSessionMessagesImages,
|
||||
mockMessages,
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for openai-completions", async () => {
|
||||
it("passes simple user-only history through for openai-completions", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
|
||||
@@ -14,7 +14,6 @@ const {
|
||||
resolveMemorySearchConfigMock,
|
||||
resolveSessionAgentIdMock,
|
||||
estimateTokensMock,
|
||||
sessionAbortCompactionMock,
|
||||
} = vi.hoisted(() => {
|
||||
const contextEngineCompactMock = vi.fn(async () => ({
|
||||
ok: true as boolean,
|
||||
@@ -66,7 +65,6 @@ const {
|
||||
})),
|
||||
resolveSessionAgentIdMock: vi.fn(() => "main"),
|
||||
estimateTokensMock: vi.fn((_message?: unknown) => 10),
|
||||
sessionAbortCompactionMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -123,7 +121,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => {
|
||||
session.messages.splice(1);
|
||||
return await sessionCompactImpl();
|
||||
}),
|
||||
abortCompaction: sessionAbortCompactionMock,
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session };
|
||||
@@ -154,7 +151,6 @@ vi.mock("../models-config.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../model-auth.js", () => ({
|
||||
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
|
||||
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
|
||||
resolveModelAuthMode: vi.fn(() => "env"),
|
||||
}));
|
||||
@@ -424,7 +420,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
resolveSessionAgentIdMock.mockReturnValue("main");
|
||||
estimateTokensMock.mockReset();
|
||||
estimateTokensMock.mockReturnValue(10);
|
||||
sessionAbortCompactionMock.mockReset();
|
||||
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
|
||||
});
|
||||
|
||||
@@ -777,24 +772,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts in-flight compaction when the caller abort signal fires", async () => {
|
||||
const controller = new AbortController();
|
||||
sessionCompactImpl.mockImplementationOnce(() => new Promise<never>(() => {}));
|
||||
|
||||
const resultPromise = compactEmbeddedPiSessionDirect(
|
||||
directCompactionArgs({
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
controller.abort(new Error("request timed out"));
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain("request timed out");
|
||||
expect(sessionAbortCompactionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user