Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9b1079283 | |||
| 584e627d77 | |||
| f8547fcae4 | |||
| 3b95aa8804 | |||
| a5147d4d88 | |||
| 971ecabe80 | |||
| 7f46b03de0 | |||
| 9d1498b2c2 | |||
| 60b7613156 | |||
| e2d0b7c583 | |||
| 72de33c976 | |||
| 148a65fe90 | |||
| 2afc655bd5 | |||
| 19e52a1ba2 | |||
| acbdafc4f4 | |||
| acca306665 | |||
| 03941e2dbf | |||
| b23ed7530b | |||
| 5ebccf5e30 | |||
| 9e1b524a00 | |||
| fcc9fd1623 | |||
| 7626d18c64 | |||
| 92fb0caf35 | |||
| f9281d1b9d | |||
| 225d58b74a | |||
| 0e0945c5ed | |||
| 5cfb979766 | |||
| 5efed49208 | |||
| c9f1506d2f | |||
| fcee6fa047 | |||
| 03826b8075 | |||
| c3a0304f63 | |||
| 3d69ad8308 | |||
| 5872f860c9 | |||
| ebb4794952 | |||
| 680c30bc5d | |||
| 3cbd3960f9 | |||
| d0e0150129 | |||
| d3673fd53e | |||
| 4e74e7e26c | |||
| 5289e8f0fe | |||
| 3847ace25b | |||
| 094524a549 | |||
| bd1c48e4d9 | |||
| dc382b09be | |||
| 0b8bc0e1b4 | |||
| 81432d6b7e | |||
| 468185d1b5 |
@@ -137,3 +137,6 @@ docs/superpowers
|
||||
|
||||
# Deprecated changelog fragment workflow
|
||||
changelog/fragments/
|
||||
|
||||
# Local scratch workspace
|
||||
.tmp/
|
||||
|
||||
@@ -21,6 +21,48 @@
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
|
||||
|
||||
## Architecture Boundaries
|
||||
|
||||
- Start here for the repo map:
|
||||
- `extensions/*` = bundled plugins and the closest example surface for third-party plugins
|
||||
- `src/plugin-sdk/*` = the public plugin contract that extensions are allowed to import
|
||||
- `src/channels/*` = core channel implementation details behind the plugin/channel boundary
|
||||
- `src/plugins/*` = plugin discovery, manifest validation, loader, registry, and contract enforcement
|
||||
- `src/gateway/protocol/*` = typed Gateway control-plane and node wire protocol
|
||||
- Progressive disclosure lives in local boundary guides:
|
||||
- `extensions/AGENTS.md`
|
||||
- `src/plugin-sdk/AGENTS.md`
|
||||
- `src/channels/AGENTS.md`
|
||||
- `src/plugins/AGENTS.md`
|
||||
- `src/gateway/protocol/AGENTS.md`
|
||||
- Plugin and extension boundary:
|
||||
- Public docs: `docs/plugins/building-plugins.md`, `docs/plugins/architecture.md`, `docs/plugins/sdk-overview.md`, `docs/plugins/sdk-entrypoints.md`, `docs/plugins/sdk-runtime.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/sdk-provider-plugins.md`
|
||||
- Definition files: `src/plugin-sdk/plugin-entry.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/channel-contract.ts`, `scripts/lib/plugin-sdk-entrypoints.json`, `package.json`
|
||||
- Rule: extensions must cross into core only through `openclaw/plugin-sdk/*`, manifest metadata, and documented runtime helpers. Do not import `src/**` from extension production code.
|
||||
- Rule: core code and tests must not deep-import bundled plugin internals such as `extensions/<id>/src/**` or `extensions/<id>/onboard.js`. If core needs a bundled plugin helper, expose it through `extensions/<id>/api.ts` and, when it is a real cross-package contract, through `src/plugin-sdk/<id>.ts`.
|
||||
- Compatibility: new plugin seams are allowed, but they must be added as documented, backwards-compatible, versioned contracts. We have third-party plugins in the wild and do not break them casually.
|
||||
- Channel boundary:
|
||||
- Public docs: `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/architecture.md`
|
||||
- Definition files: `src/channels/plugins/types.plugin.ts`, `src/channels/plugins/types.core.ts`, `src/channels/plugins/types.adapters.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/channel-contract.ts`
|
||||
- Rule: `src/channels/**` is core implementation. If plugin authors need a new seam, add it to the Plugin SDK instead of telling them to import channel internals.
|
||||
- Provider/model boundary:
|
||||
- Public docs: `docs/plugins/sdk-provider-plugins.md`, `docs/concepts/model-providers.md`, `docs/plugins/architecture.md`
|
||||
- Definition files: `src/plugins/types.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/provider-auth.ts`, `src/plugin-sdk/provider-catalog-shared.ts`, `src/plugin-sdk/provider-model-shared.ts`
|
||||
- Rule: core owns the generic inference loop; provider plugins own provider-specific behavior through registration and typed hooks. Do not solve provider needs by reaching into unrelated core internals.
|
||||
- Rule: avoid ad hoc reads of `plugins.entries.<id>.config` from unrelated core code. If core needs plugin-owned auth/config behavior, add or use a generic seam (`resolveSyntheticAuth`, public SDK/helper facades, manifest metadata, plugin auto-enable hooks) and honor plugin disablement plus SecretRef semantics.
|
||||
- Rule: vendor-owned tools and settings belong in the owning plugin. Do not add provider-specific tool config, secret collection, or runtime enablement to core `tools.*` surfaces unless the tool is intentionally core-owned.
|
||||
- Gateway protocol boundary:
|
||||
- Public docs: `docs/gateway/protocol.md`, `docs/gateway/bridge-protocol.md`, `docs/concepts/architecture.md`
|
||||
- Definition files: `src/gateway/protocol/schema.ts`, `src/gateway/protocol/schema/*.ts`, `src/gateway/protocol/index.ts`
|
||||
- Rule: protocol changes are contract changes. Prefer additive evolution; incompatible changes require explicit versioning, docs, and client/codegen follow-through.
|
||||
- Bundled plugin contract boundary:
|
||||
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
|
||||
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/extensions/public-artifacts.ts`
|
||||
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
|
||||
- Extension test boundary:
|
||||
- Keep extension-owned onboarding/config/provider coverage under `extensions/<id>/**` when feasible.
|
||||
- If core tests need bundled plugin behavior, consume it through public `src/plugin-sdk/<id>.ts` facades or `extensions/<id>/api.ts`, not private extension modules.
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
|
||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
||||
|
||||
+16
-1
@@ -4,7 +4,18 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2026.3.28-beta.1
|
||||
### Fixes
|
||||
|
||||
- macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape.
|
||||
- Memory/QMD: resolve slugified `memory_search` file hints back to the indexed filesystem path before returning search hits, so `memory_get` works again for mixed-case and spaced paths. (#50313) Thanks @erra9x.
|
||||
- Memory/QMD: weight CJK-heavy text correctly when estimating chunk sizes, preserve surrogate-pair characters during fine splits, and keep long Latin lines on the old chunk boundaries so memory indexing produces better-sized chunks for CJK notes. (#40271) Thanks @AaronLuo00.
|
||||
- Security/LINE: make webhook signature validation run the timing-safe compare even when the supplied signature length is wrong, closing a small timing side-channel. (#55663) Thanks @gavyngong.
|
||||
- LINE/status: stop `openclaw status` from warning about missing credentials when sanitized LINE snapshots are already configured, while still surfacing whether the missing field is the token or secret. (#45701) Thanks @tamaosamu.
|
||||
- Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.
|
||||
- Memory/QMD: honor `memory.qmd.update.embedInterval` even when regular QMD update cadence is disabled or slower by arming a dedicated embed-cadence maintenance timer, while avoiding redundant timers when regular updates are already frequent enough. (#37326) Thanks @barronlroth.
|
||||
- Agents/memory flush: keep daily memory flush files append-only during embedded attempts so compaction writes do not overwrite earlier notes. (#53725) Thanks @HPluseven.
|
||||
|
||||
## 2026.3.28
|
||||
|
||||
### Breaking
|
||||
|
||||
@@ -14,6 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- xAI/tools: move the bundled xAI provider to the Responses API, add first-class `x_search`, and auto-enable the xAI plugin from owned web-search and tool config so bundled Grok auth/configured search flows work without manual plugin toggles. (#56048) Thanks @huntharo.
|
||||
- xAI/onboarding: let the bundled Grok web-search plugin offer optional `x_search` setup during `openclaw onboard` and `openclaw configure --section web`, including an x_search model picker with the shared xAI key.
|
||||
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
|
||||
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
|
||||
- ACP/channels: add current-conversation ACP binds for Discord, BlueBubbles, and iMessage so `/acp spawn codex --bind here` can turn the current chat into a Codex-backed workspace without creating a child thread, and document the distinction between chat surface, ACP session, and runtime workspace.
|
||||
@@ -36,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Anthropic: recover unhandled provider stop reasons (e.g. `sensitive`) as structured assistant errors instead of crashing the agent run. (#56639)
|
||||
- Google/models: resolve Gemini 3.1 pro, flash, and flash-lite for all Google provider aliases by passing the actual runtime provider ID and adding a template-provider fallback; fix flash-lite prefix ordering. (#56567)
|
||||
- OpenAI Codex/image tools: register Codex for media understanding and route image prompts through Codex instructions so image analysis no longer fails on missing provider registration or missing `instructions`. (#54829) Thanks @neeravmakwana.
|
||||
- Agents/image tool: restore the generic image-runtime fallback when no provider-specific media-understanding provider is registered, so image analysis works again for providers like `openrouter` and `minimax-portal`. (#54858) Thanks @MonkeyLeeT.
|
||||
@@ -130,6 +143,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan.
|
||||
- Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348)
|
||||
- Plugins/Matrix: load bundled `@matrix-org/matrix-sdk-crypto-nodejs` through `createRequire(...)` so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.
|
||||
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
|
||||
- Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963)
|
||||
|
||||
## 2026.3.24
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026032801
|
||||
versionName = "2026.3.28-beta.1"
|
||||
versionCode = 2026032800
|
||||
versionName = "2026.3.28"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.28-beta.1
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.28
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.28
|
||||
OPENCLAW_BUILD_VERSION = 2026032801
|
||||
OPENCLAW_BUILD_VERSION = 2026032800
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -193,7 +193,7 @@ enum GatewayEnvironment {
|
||||
let port = self.gatewayPort()
|
||||
if let gatewayBin {
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ enum GatewayEnvironment {
|
||||
case let .success(resolvedRuntime) = runtime
|
||||
{
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ final class GatewayProcessManager {
|
||||
private var logRefreshTask: Task<Void, Never>?
|
||||
#if DEBUG
|
||||
private var testingConnection: GatewayConnection?
|
||||
private var testingSkipControlChannelRefresh = false
|
||||
#endif
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process")
|
||||
|
||||
@@ -364,6 +365,11 @@ final class GatewayProcessManager {
|
||||
}
|
||||
|
||||
private func refreshControlChannelIfNeeded(reason: String) {
|
||||
#if DEBUG
|
||||
if self.testingSkipControlChannelRefresh {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected, .connecting:
|
||||
return
|
||||
@@ -421,6 +427,10 @@ extension GatewayProcessManager {
|
||||
self.testingConnection = connection
|
||||
}
|
||||
|
||||
func setTestingSkipControlChannelRefresh(_ skip: Bool) {
|
||||
self.testingSkipControlChannelRefresh = skip
|
||||
}
|
||||
|
||||
func setTestingDesiredActive(_ active: Bool) {
|
||||
self.desiredActive = active
|
||||
}
|
||||
@@ -428,5 +438,9 @@ extension GatewayProcessManager {
|
||||
func setTestingLastFailureReason(_ reason: String?) {
|
||||
self.lastFailureReason = reason
|
||||
}
|
||||
|
||||
func _testAttachExistingGatewayIfAvailable() async -> Bool {
|
||||
await self.attachExistingGatewayIfAvailable()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -23,6 +23,9 @@ actor PortGuardian {
|
||||
|
||||
private var records: [Record] = []
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "portguard")
|
||||
#if DEBUG
|
||||
private var testingDescriptors: [Int: Descriptor] = [:]
|
||||
#endif
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
@@ -130,6 +133,11 @@ actor PortGuardian {
|
||||
}
|
||||
|
||||
func describe(port: Int) async -> Descriptor? {
|
||||
#if DEBUG
|
||||
if let descriptor = self.testingDescriptors[port] {
|
||||
return descriptor
|
||||
}
|
||||
#endif
|
||||
guard let listener = await self.listeners(on: port).first else { return nil }
|
||||
let path = Self.executablePath(for: listener.pid)
|
||||
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
||||
@@ -368,8 +376,12 @@ actor PortGuardian {
|
||||
if port == GatewayEnvironment.gatewayPort() { return true }
|
||||
return false
|
||||
case .local:
|
||||
// The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc).
|
||||
if full.contains("gateway-daemon") { return true }
|
||||
// Preserve both the legacy hidden alias and the current service process title.
|
||||
if full.contains("gateway-daemon") || full.contains("openclaw-gateway")
|
||||
|| cmd.contains("openclaw-gateway")
|
||||
{
|
||||
return true
|
||||
}
|
||||
// If args are unavailable, treat a CLI listener as expected.
|
||||
if cmd.contains("openclaw"), full == cmd { return true }
|
||||
return false
|
||||
@@ -402,6 +414,18 @@ actor PortGuardian {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PortGuardian {
|
||||
func setTestingDescriptor(_ descriptor: Descriptor?, forPort port: Int) {
|
||||
if let descriptor {
|
||||
self.testingDescriptors[port] = descriptor
|
||||
} else {
|
||||
self.testingDescriptors.removeValue(forKey: port)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
extension PortGuardian {
|
||||
static func _testParseListeners(_ text: String) -> [(
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.28-beta.1</string>
|
||||
<string>2026.3.28</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026032801</string>
|
||||
<string>2026032800</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -7,7 +7,7 @@ struct GatewayLaunchAgentManagerTests {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
|
||||
let plist: [String: Any] = [
|
||||
"ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
||||
"ProgramArguments": ["openclaw", "gateway", "--port", "18789", "--bind", "loopback"],
|
||||
"EnvironmentVariables": [
|
||||
"OPENCLAW_GATEWAY_TOKEN": " secret ",
|
||||
"OPENCLAW_GATEWAY_PASSWORD": "pw",
|
||||
@@ -28,7 +28,7 @@ struct GatewayLaunchAgentManagerTests {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
|
||||
let plist: [String: Any] = [
|
||||
"ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789"],
|
||||
"ProgramArguments": ["openclaw", "gateway", "--port", "18789"],
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
|
||||
@@ -35,4 +35,92 @@ struct GatewayProcessManagerTests {
|
||||
#expect(ready)
|
||||
#expect(manager.lastFailureReason == nil)
|
||||
}
|
||||
|
||||
@Test func `attaches to existing gateway without spawning launchd`() async throws {
|
||||
let healthData = Data(
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"ts": 1,
|
||||
"durationMs": 0,
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 60000
|
||||
}
|
||||
},
|
||||
"channelOrder": ["telegram"],
|
||||
"channelLabels": {
|
||||
"telegram": "Telegram"
|
||||
},
|
||||
"heartbeatSeconds": 30,
|
||||
"sessions": {
|
||||
"path": "/tmp/sessions",
|
||||
"count": 1,
|
||||
"recent": []
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { task, message, sendIndex in
|
||||
guard sendIndex > 0 else { return }
|
||||
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": \(String(decoding: healthData, as: UTF8.self))
|
||||
}
|
||||
"""
|
||||
task.emitReceiveSuccess(.data(Data(json.utf8)))
|
||||
})
|
||||
})
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let connection = GatewayConnection(
|
||||
configProvider: { (url: url, token: nil, password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let descriptor = PortGuardian.Descriptor(
|
||||
pid: 4242,
|
||||
command: "openclaw-gateway",
|
||||
executablePath: "/tmp/openclaw-gateway")
|
||||
|
||||
let manager = GatewayProcessManager.shared
|
||||
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
||||
manager.setTestingConnection(connection)
|
||||
manager.setTestingSkipControlChannelRefresh(true)
|
||||
manager.setTestingLastFailureReason("stale")
|
||||
|
||||
func cleanup() async {
|
||||
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
||||
manager.setTestingConnection(nil)
|
||||
manager.setTestingSkipControlChannelRefresh(false)
|
||||
manager.setTestingDesiredActive(false)
|
||||
manager.setTestingLastFailureReason(nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let attached = await manager._testAttachExistingGatewayIfAvailable()
|
||||
#expect(attached)
|
||||
#expect(manager.lastFailureReason == nil)
|
||||
guard case let .attachedExisting(statusDetails) = manager.status else {
|
||||
Issue.record("expected attachedExisting status")
|
||||
await cleanup()
|
||||
return
|
||||
}
|
||||
let details = try #require(statusDetails)
|
||||
#expect(details.contains("port \(port)"))
|
||||
#expect(details.contains("Telegram linked"))
|
||||
#expect(details.contains("auth 1m"))
|
||||
#expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway"))
|
||||
await cleanup()
|
||||
} catch {
|
||||
await cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,11 @@ struct LowCoverageHelperTests {
|
||||
fullCommand: "python server.py",
|
||||
port: 18789, mode: .local) == false)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "node",
|
||||
fullCommand: "openclaw-gateway",
|
||||
port: 18789, mode: .local) == true)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "node",
|
||||
fullCommand: "node /path/to/gateway-daemon",
|
||||
|
||||
@@ -3,17 +3,19 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct NodeServiceManagerTests {
|
||||
@Test func `builds node service commands with current CLI shape`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
@Test func `builds node service commands with current CLI shape`() async throws {
|
||||
try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) {
|
||||
let tmp = try makeTempDirForTests()
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
|
||||
let start = NodeServiceManager._testServiceCommand(["start"])
|
||||
#expect(start == [openclawPath.path, "node", "start", "--json"])
|
||||
let start = NodeServiceManager._testServiceCommand(["start"])
|
||||
#expect(start == [openclawPath.path, "node", "start", "--json"])
|
||||
|
||||
let stop = NodeServiceManager._testServiceCommand(["stop"])
|
||||
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
|
||||
let stop = NodeServiceManager._testServiceCommand(["stop"])
|
||||
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
"exportName": "CliBackendPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1607,
|
||||
"line": 1616,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -397,7 +397,7 @@
|
||||
"exportName": "MediaUnderstandingProviderPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1258,
|
||||
"line": 1267,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -415,7 +415,7 @@
|
||||
"exportName": "OpenClawPluginApi",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1651,
|
||||
"line": 1660,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -523,7 +523,7 @@
|
||||
"exportName": "SpeechProviderPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1233,
|
||||
"line": 1242,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3747,7 +3747,7 @@
|
||||
"exportName": "MediaUnderstandingProviderPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1258,
|
||||
"line": 1267,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3765,7 +3765,7 @@
|
||||
"exportName": "OpenClawPluginApi",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1651,
|
||||
"line": 1660,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3774,7 +3774,7 @@
|
||||
"exportName": "OpenClawPluginCommandDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1377,
|
||||
"line": 1386,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3792,7 +3792,7 @@
|
||||
"exportName": "OpenClawPluginDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1633,
|
||||
"line": 1642,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3801,7 +3801,7 @@
|
||||
"exportName": "OpenClawPluginService",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1600,
|
||||
"line": 1609,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3810,7 +3810,7 @@
|
||||
"exportName": "OpenClawPluginServiceContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1592,
|
||||
"line": 1601,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3837,7 +3837,7 @@
|
||||
"exportName": "PluginCommandContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1273,
|
||||
"line": 1282,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3846,7 +3846,7 @@
|
||||
"exportName": "PluginInteractiveTelegramHandlerContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1406,
|
||||
"line": 1415,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4170,7 +4170,7 @@
|
||||
"exportName": "SpeechProviderPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1233,
|
||||
"line": 1242,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4262,7 +4262,7 @@
|
||||
"exportName": "MediaUnderstandingProviderPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1258,
|
||||
"line": 1267,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4280,7 +4280,7 @@
|
||||
"exportName": "OpenClawPluginApi",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1651,
|
||||
"line": 1660,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4289,7 +4289,7 @@
|
||||
"exportName": "OpenClawPluginCommandDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1377,
|
||||
"line": 1386,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4307,7 +4307,7 @@
|
||||
"exportName": "OpenClawPluginDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1633,
|
||||
"line": 1642,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4316,7 +4316,7 @@
|
||||
"exportName": "OpenClawPluginService",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1600,
|
||||
"line": 1609,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4325,7 +4325,7 @@
|
||||
"exportName": "OpenClawPluginServiceContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1592,
|
||||
"line": 1601,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4352,7 +4352,7 @@
|
||||
"exportName": "PluginCommandContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1273,
|
||||
"line": 1282,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4361,7 +4361,7 @@
|
||||
"exportName": "PluginInteractiveTelegramHandlerContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1406,
|
||||
"line": 1415,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4667,7 +4667,7 @@
|
||||
"exportName": "SpeechProviderPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1233,
|
||||
"line": 1242,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"index","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"ClawdbotConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
|
||||
{"declaration":"export type CliBackendConfig = CliBackendConfig;","entrypoint":"index","exportName":"CliBackendConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/config/types.agent-defaults.ts"}
|
||||
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1607,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1616,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type CompiledConfiguredBinding = CompiledConfiguredBinding;","entrypoint":"index","exportName":"CompiledConfiguredBinding","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/binding-types.ts"}
|
||||
{"declaration":"export type ConfiguredBindingConversation = ConversationRef;","entrypoint":"index","exportName":"ConfiguredBindingConversation","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/binding-types.ts"}
|
||||
{"declaration":"export type ConfiguredBindingResolution = ConfiguredBindingResolution;","entrypoint":"index","exportName":"ConfiguredBindingResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/binding-types.ts"}
|
||||
@@ -42,9 +42,9 @@
|
||||
{"declaration":"export type ImageGenerationResolution = ImageGenerationResolution;","entrypoint":"index","exportName":"ImageGenerationResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":12,"sourcePath":"src/image-generation/types.ts"}
|
||||
{"declaration":"export type ImageGenerationResult = ImageGenerationResult;","entrypoint":"index","exportName":"ImageGenerationResult","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":36,"sourcePath":"src/image-generation/types.ts"}
|
||||
{"declaration":"export type ImageGenerationSourceImage = ImageGenerationSourceImage;","entrypoint":"index","exportName":"ImageGenerationSourceImage","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/image-generation/types.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1258,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1267,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1651,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1660,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"index","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"index","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
|
||||
@@ -56,7 +56,7 @@
|
||||
{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/plugins/runtime/types-core.ts"}
|
||||
{"declaration":"export type SecretInput = SecretInput;","entrypoint":"index","exportName":"SecretInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/config/types.secrets.ts"}
|
||||
{"declaration":"export type SecretRef = SecretRef;","entrypoint":"index","exportName":"SecretRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":10,"sourcePath":"src/config/types.secrets.ts"}
|
||||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"index","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1233,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"index","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1242,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type StatefulBindingTargetDescriptor = StatefulBindingTargetDescriptor;","entrypoint":"index","exportName":"StatefulBindingTargetDescriptor","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":17,"sourcePath":"src/channels/plugins/binding-types.ts"}
|
||||
{"declaration":"export type StatefulBindingTargetDriver = StatefulBindingTargetDriver;","entrypoint":"index","exportName":"StatefulBindingTargetDriver","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/channels/plugins/stateful-target-drivers.ts"}
|
||||
{"declaration":"export type StatefulBindingTargetReadyResult = StatefulBindingTargetReadyResult;","entrypoint":"index","exportName":"StatefulBindingTargetReadyResult","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/channels/plugins/stateful-target-drivers.ts"}
|
||||
@@ -412,18 +412,18 @@
|
||||
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"}
|
||||
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/gateway/server-methods/types.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1258,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1267,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1651,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1377,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1660,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1386,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1633,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1600,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1592,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1642,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1609,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1601,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"core","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"core","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":140,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1273,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1406,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1282,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1415,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"core","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
|
||||
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"core","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":709,"sourcePath":"src/plugins/types.ts"}
|
||||
@@ -459,7 +459,7 @@
|
||||
{"declaration":"export type RoutePeerKind = ChatType;","entrypoint":"core","exportName":"RoutePeerKind","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/routing/resolve-route.ts"}
|
||||
{"declaration":"export type SecretFileReadOptions = SecretFileReadOptions;","entrypoint":"core","exportName":"SecretFileReadOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/infra/secret-file.ts"}
|
||||
{"declaration":"export type SecretFileReadResult = SecretFileReadResult;","entrypoint":"core","exportName":"SecretFileReadResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":12,"sourcePath":"src/infra/secret-file.ts"}
|
||||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"core","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1233,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"core","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1242,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type TailscaleStatusCommandResult = TailscaleStatusCommandResult;","entrypoint":"core","exportName":"TailscaleStatusCommandResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/shared/tailscale-status.ts"}
|
||||
{"declaration":"export type TailscaleStatusCommandRunner = TailscaleStatusCommandRunner;","entrypoint":"core","exportName":"TailscaleStatusCommandRunner","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/shared/tailscale-status.ts"}
|
||||
{"declaration":"export type UsageProviderId = UsageProviderId;","entrypoint":"core","exportName":"UsageProviderId","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":20,"sourcePath":"src/infra/provider-usage.types.ts"}
|
||||
@@ -469,18 +469,18 @@
|
||||
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":137,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":108,"sourcePath":"src/plugins/config-schema.ts"}
|
||||
{"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"plugin-entry","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1258,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1267,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1651,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1377,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1660,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1386,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1633,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1600,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1592,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1642,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1609,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1601,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":140,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1273,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1406,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1282,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1415,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"plugin-entry","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"plugin-entry","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":709,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"plugin-entry","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":175,"sourcePath":"src/plugins/types.ts"}
|
||||
@@ -514,7 +514,7 @@
|
||||
{"declaration":"export type ProviderRuntimeModel = ProviderRuntimeModel;","entrypoint":"plugin-entry","exportName":"ProviderRuntimeModel","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":316,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type ProviderThinkingPolicyContext = ProviderThinkingPolicyContext;","entrypoint":"plugin-entry","exportName":"ProviderThinkingPolicyContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":674,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type ProviderWrapStreamFnContext = ProviderWrapStreamFnContext;","entrypoint":"plugin-entry","exportName":"ProviderWrapStreamFnContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":560,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"plugin-entry","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1233,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"plugin-entry","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1242,"sourcePath":"src/plugins/types.ts"}
|
||||
{"category":"provider","entrypoint":"provider-onboard","importSpecifier":"openclaw/plugin-sdk/provider-onboard","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
|
||||
{"declaration":"export function applyAgentDefaultModelPrimary(cfg: OpenClawConfig, primary: string): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyAgentDefaultModelPrimary","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":267,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
|
||||
{"declaration":"export function applyOnboardAuthAgentModelsAndProviders(cfg: OpenClawConfig, params: { agentModels: Record<string, AgentModelEntryConfig>; providers: Record<string, ModelProviderConfig>; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyOnboardAuthAgentModelsAndProviders","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":244,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
|
||||
|
||||
@@ -470,6 +470,23 @@ Matrix supports native Matrix threads for both automatic replies and message-too
|
||||
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
|
||||
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
|
||||
|
||||
## ACP conversation bindings
|
||||
|
||||
Matrix rooms, DMs, and existing Matrix threads can be turned into durable ACP workspaces without changing the chat surface.
|
||||
|
||||
Fast operator flow:
|
||||
|
||||
- Run `/acp spawn codex --bind here` inside the Matrix DM, room, or existing thread you want to keep using.
|
||||
- In a top-level Matrix DM or room, the current DM/room stays the chat surface and future messages route to the spawned ACP session.
|
||||
- Inside an existing Matrix thread, `--bind here` binds that current thread in place.
|
||||
- `/new` and `/reset` reset the same bound ACP session in place.
|
||||
- `/acp close` closes the ACP session and removes the binding.
|
||||
|
||||
Notes:
|
||||
|
||||
- `--bind here` does not create a child Matrix thread.
|
||||
- `threadBindings.spawnAcpSessions` is only required for `/acp spawn --thread auto|here`, where OpenClaw needs to create or bind a child Matrix thread.
|
||||
|
||||
### Thread Binding Config
|
||||
|
||||
Matrix inherits global defaults from `session.threadBindings`, and also supports per-channel overrides:
|
||||
|
||||
@@ -15,6 +15,11 @@ Note: The **Model** section now includes a multi-select for the
|
||||
Tip: `openclaw config` without a subcommand opens the same wizard. Use
|
||||
`openclaw config get|set|unset` for non-interactive edits.
|
||||
|
||||
For web search, `openclaw configure --section web` lets you choose a provider
|
||||
and configure its credentials. If you choose **Grok**, configure can also show
|
||||
a separate follow-up step to enable `x_search` with the same `XAI_API_KEY` and
|
||||
pick an `x_search` model. Other web-search providers do not show that step.
|
||||
|
||||
Related:
|
||||
|
||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||
@@ -32,5 +37,6 @@ Notes:
|
||||
|
||||
```bash
|
||||
openclaw configure
|
||||
openclaw configure --section web
|
||||
openclaw configure --section model --section channels
|
||||
```
|
||||
|
||||
@@ -140,6 +140,9 @@ Flow notes:
|
||||
|
||||
- `quickstart`: minimal prompts, auto-generates a gateway token.
|
||||
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
|
||||
- In the web-search step, choosing **Grok** can trigger a separate follow-up
|
||||
prompt to enable `x_search` with the same `XAI_API_KEY` and optionally pick
|
||||
an `x_search` model. Other web-search providers do not show that prompt.
|
||||
- Local onboarding DM scope behavior: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals).
|
||||
- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup).
|
||||
- Custom Provider: connect any OpenAI or Anthropic compatible endpoint,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -21,6 +21,22 @@ For post-level X metrics such as reposts, replies, bookmarks, or views, prefer
|
||||
`x_search` with the exact post URL or status ID instead of a broad search
|
||||
query.
|
||||
|
||||
## Onboarding and configure
|
||||
|
||||
If you choose **Grok** during:
|
||||
|
||||
- `openclaw onboard`
|
||||
- `openclaw configure --section web`
|
||||
|
||||
OpenClaw can show a separate follow-up step to enable `x_search` with the same
|
||||
`XAI_API_KEY`. That follow-up:
|
||||
|
||||
- only appears after you choose Grok for `web_search`
|
||||
- is not a separate top-level web-search provider choice
|
||||
- can optionally set the `x_search` model during the same flow
|
||||
|
||||
If you skip it, you can enable or change `x_search` later in config.
|
||||
|
||||
## Get an API key
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -149,6 +149,11 @@ examples.
|
||||
|
||||
For `x_search`, configure `tools.web.x_search.*` directly. It uses the same
|
||||
`XAI_API_KEY` fallback as Grok web search.
|
||||
When you choose Grok during `openclaw onboard` or `openclaw configure --section web`,
|
||||
OpenClaw can also offer optional `x_search` setup with the same key.
|
||||
This is a separate follow-up step inside the Grok path, not a separate top-level
|
||||
web-search provider choice. If you pick another provider, OpenClaw does not
|
||||
show the `x_search` prompt.
|
||||
|
||||
### Storing API keys
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,47 @@
|
||||
# Extensions Boundary
|
||||
|
||||
This directory contains bundled plugins. Treat it as the same boundary that
|
||||
third-party plugins see.
|
||||
|
||||
## Public Contracts
|
||||
|
||||
- Docs:
|
||||
- `docs/plugins/building-plugins.md`
|
||||
- `docs/plugins/architecture.md`
|
||||
- `docs/plugins/sdk-overview.md`
|
||||
- `docs/plugins/sdk-entrypoints.md`
|
||||
- `docs/plugins/sdk-runtime.md`
|
||||
- `docs/plugins/sdk-channel-plugins.md`
|
||||
- `docs/plugins/sdk-provider-plugins.md`
|
||||
- `docs/plugins/manifest.md`
|
||||
- Definition files:
|
||||
- `src/plugin-sdk/plugin-entry.ts`
|
||||
- `src/plugin-sdk/core.ts`
|
||||
- `src/plugin-sdk/provider-entry.ts`
|
||||
- `src/plugin-sdk/channel-contract.ts`
|
||||
- `scripts/lib/plugin-sdk-entrypoints.json`
|
||||
- `package.json`
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
- Extension production code should import from `openclaw/plugin-sdk/*` and its
|
||||
own local barrels such as `./api.ts` and `./runtime-api.ts`.
|
||||
- Do not import core internals from `src/**`, `src/channels/**`,
|
||||
`src/plugin-sdk-internal/**`, or another extension's `src/**`.
|
||||
- Do not use relative imports that escape the current extension package root.
|
||||
- Keep plugin metadata accurate in `openclaw.plugin.json` and the package
|
||||
`openclaw` block so discovery and setup work without executing plugin code.
|
||||
- Treat files like `src/**`, `onboard.ts`, and other local helpers as private
|
||||
unless you intentionally promote them through `api.ts` and, if needed, a
|
||||
matching `src/plugin-sdk/<id>.ts` facade.
|
||||
- If core or core tests need a bundled plugin helper, export it from `api.ts`
|
||||
first instead of letting them deep-import extension internals.
|
||||
|
||||
## Expanding The Boundary
|
||||
|
||||
- If an extension needs a new seam, add a typed Plugin SDK subpath or additive
|
||||
export instead of reaching into core.
|
||||
- Keep new plugin-facing seams backwards-compatible and versioned. Third-party
|
||||
plugins consume this surface.
|
||||
- When intentionally expanding the contract, update the docs, exported subpath
|
||||
list, package exports, and API/contract checks in the same change.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
@@ -58,7 +58,7 @@ export const bluebubblesConfigAdapter =
|
||||
});
|
||||
|
||||
export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) {
|
||||
return describeAccountSnapshot({
|
||||
return describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/duckduckgo-plugin",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw DuckDuckGo plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/elevenlabs-speech",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw ElevenLabs speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Exa plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fal-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw fal provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/firecrawl-plugin",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Firecrawl plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/github-copilot-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw GitHub Copilot provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-plugin",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
@@ -11,7 +11,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/groq-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Groq media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/huggingface-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Hugging Face provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/image-generation-core",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw image generation runtime package",
|
||||
"type": "module"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/kilocode-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Kilo Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/kimi-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw Kimi provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import type { ChannelPlugin } from "../api.js";
|
||||
import {
|
||||
resolveLineAccount,
|
||||
@@ -37,13 +38,14 @@ export const lineChannelPluginCommon = {
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account: ResolvedLineAccount) => hasLineCredentials(account),
|
||||
describeAccount: (account: ResolvedLineAccount) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: hasLineCredentials(account),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
describeAccount: (account: ResolvedLineAccount) =>
|
||||
describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: hasLineCredentials(account),
|
||||
extra: {
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} satisfies Pick<
|
||||
ChannelPlugin<ResolvedLineAccount>,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelAccountSnapshot } from "../api.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
|
||||
function collectIssues(accounts: ChannelAccountSnapshot[]) {
|
||||
const collect = linePlugin.status?.collectStatusIssues;
|
||||
if (!collect) {
|
||||
throw new Error("LINE plugin status collector is unavailable");
|
||||
}
|
||||
return collect(accounts);
|
||||
}
|
||||
|
||||
describe("linePlugin status.collectStatusIssues", () => {
|
||||
it("does not warn when a sanitized snapshot is configured", () => {
|
||||
expect(
|
||||
collectIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports missing access token when the snapshot is unconfigured and tokenSource is none", () => {
|
||||
expect(
|
||||
collectIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: false,
|
||||
tokenSource: "none",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "LINE channel access token not configured",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing secret when the snapshot is unconfigured but a token source exists", () => {
|
||||
expect(
|
||||
collectIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: false,
|
||||
tokenSource: "env",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "LINE channel secret not configured",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,16 @@
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createAttachedChannelResultAdapter,
|
||||
createEmptyChannelResult,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
buildTokenChannelStatusSummary,
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
type LineConfig,
|
||||
type LineChannelData,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { probeLineBot } from "./probe.js";
|
||||
import { lineOutboundAdapter } from "./outbound.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
@@ -77,157 +59,8 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
},
|
||||
directory: createEmptyChannelDirectoryAdapter(),
|
||||
setup: lineSetupAdapter,
|
||||
status: createComputedAccountStatusAdapter<ResolvedLineAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts) => {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const account of accounts) {
|
||||
const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
if (!account.channelAccessToken?.trim()) {
|
||||
issues.push({
|
||||
channel: "line",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "LINE channel access token not configured",
|
||||
});
|
||||
}
|
||||
if (!account.channelSecret?.trim()) {
|
||||
issues.push({
|
||||
channel: "line",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "LINE channel secret not configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
resolveAccountSnapshot: ({ account }) => {
|
||||
const configured = Boolean(
|
||||
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
|
||||
);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: "webhook",
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) {
|
||||
lineBotLabel = ` (${displayName})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
return await getLineRuntime().channel.line.monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accountCleanup = clearAccountEntryFields({
|
||||
accounts: nextLine.accounts,
|
||||
accountId,
|
||||
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
|
||||
markClearedOnFieldPresence: true,
|
||||
});
|
||||
if (accountCleanup.changed) {
|
||||
changed = true;
|
||||
if (accountCleanup.cleared) {
|
||||
cleared = true;
|
||||
}
|
||||
if (accountCleanup.nextAccounts) {
|
||||
nextLine.accounts = accountCleanup.nextAccounts;
|
||||
} else {
|
||||
delete nextLine.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
},
|
||||
status: lineStatusAdapter,
|
||||
gateway: lineGatewayAdapter,
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
@@ -298,227 +131,5 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
},
|
||||
},
|
||||
security: lineSecurityAdapter,
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
|
||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||
|
||||
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||
const quickReplies = lineData.quickReplies ?? [];
|
||||
const hasQuickReplies = quickReplies.length > 0;
|
||||
const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined;
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
// LINE SDK expects Message[] but we build dynamically
|
||||
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||
const result = await sendBatch(to, batch, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
|
||||
fallbackLimit: 5000,
|
||||
}) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
const sendMediaMessages = async () => {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
// LINE SDK expects FlexContainer but we receive contents as unknown
|
||||
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
// LINE SDK expects FlexContainer but we receive contents as unknown
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i], {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||
contents: lineData.flexMessage.contents,
|
||||
});
|
||||
}
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
quickReplyMessages.push(template);
|
||||
}
|
||||
}
|
||||
if (lineData.location) {
|
||||
quickReplyMessages.push({
|
||||
type: "location",
|
||||
title: lineData.location.title.slice(0, 100),
|
||||
address: lineData.location.address.slice(0, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: flexMsg.altText.slice(0, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
return createEmptyChannelResult("line", { ...lastResult });
|
||||
}
|
||||
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "line",
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const processed = processLineMessage(text);
|
||||
let result: { messageId: string; chatId: string };
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
result = { messageId: "processed", chatId: to };
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
|
||||
await getLineRuntime().channel.line.sendMessageLine(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
outbound: lineOutboundAdapter,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type ChannelPlugin,
|
||||
type LineConfig,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) {
|
||||
lineBotLabel = ` (${displayName})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
return await getLineRuntime().channel.line.monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accountCleanup = clearAccountEntryFields({
|
||||
accounts: nextLine.accounts,
|
||||
accountId,
|
||||
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
|
||||
markClearedOnFieldPresence: true,
|
||||
});
|
||||
if (accountCleanup.changed) {
|
||||
changed = true;
|
||||
if (accountCleanup.cleared) {
|
||||
cleared = true;
|
||||
}
|
||||
if (accountCleanup.nextAccounts) {
|
||||
nextLine.accounts = accountCleanup.nextAccounts;
|
||||
} else {
|
||||
delete nextLine.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
createAttachedChannelResultAdapter,
|
||||
createEmptyChannelResult,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type LineChannelData,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000,
|
||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||
|
||||
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||
const quickReplies = lineData.quickReplies ?? [];
|
||||
const hasQuickReplies = quickReplies.length > 0;
|
||||
const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined;
|
||||
|
||||
// LINE SDK expects Message[] but we build dynamically.
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||
const result = await sendBatch(to, batch, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
|
||||
fallbackLimit: 5000,
|
||||
}) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
const sendMediaMessages = async () => {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i], {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||
contents: lineData.flexMessage.contents,
|
||||
});
|
||||
}
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
quickReplyMessages.push(template);
|
||||
}
|
||||
}
|
||||
if (lineData.location) {
|
||||
quickReplyMessages.push({
|
||||
type: "location",
|
||||
title: lineData.location.title.slice(0, 100),
|
||||
address: lineData.location.address.slice(0, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: flexMsg.altText.slice(0, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
return createEmptyChannelResult("line", { ...lastResult });
|
||||
}
|
||||
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "line",
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const processed = processLineMessage(text);
|
||||
let result: { messageId: string; chatId: string };
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
result = { messageId: "processed", chatId: to };
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
|
||||
await getLineRuntime().channel.line.sendMessageLine(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { validateLineSignature } from "./signature.js";
|
||||
|
||||
function sign(body: string, secret: string): string {
|
||||
return crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||
}
|
||||
|
||||
describe("validateLineSignature", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accepts a valid signature", () => {
|
||||
const body = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const secret = "top-secret";
|
||||
|
||||
expect(validateLineSignature(body, sign(body, secret), secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("still performs timing-safe comparison when signature length mismatches", () => {
|
||||
const body = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const secret = "top-secret";
|
||||
const spy = vi.spyOn(crypto, "timingSafeEqual");
|
||||
|
||||
expect(validateLineSignature(body, "short", secret)).toBe(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [left, right] = spy.mock.calls[0] ?? [];
|
||||
expect(left).toBeInstanceOf(Buffer);
|
||||
expect(right).toBeInstanceOf(Buffer);
|
||||
expect(left?.byteLength).toBe(right?.byteLength);
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,16 @@ export function validateLineSignature(
|
||||
const hashBuffer = Buffer.from(hash);
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks.
|
||||
if (hashBuffer.length !== signatureBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
// Pad to equal length before constant-time comparison to prevent
|
||||
// leaking length information via early-return timing.
|
||||
const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
|
||||
const paddedHash = Buffer.alloc(maxLen);
|
||||
const paddedSig = Buffer.alloc(maxLen);
|
||||
hashBuffer.copy(paddedHash);
|
||||
signatureBuffer.copy(paddedSig);
|
||||
|
||||
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
|
||||
// Call timingSafeEqual unconditionally to ensure constant-time execution
|
||||
// regardless of length mismatch (avoids && short-circuit timing leak).
|
||||
const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
|
||||
return hashBuffer.length === signatureBuffer.length && timingResult;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
buildTokenChannelStatusSummary,
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
createDependentCredentialStatusIssueCollector,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
|
||||
import { hasLineCredentials } from "./account-helpers.js";
|
||||
import { probeLineBot } from "./probe.js";
|
||||
|
||||
const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
|
||||
channel: "line",
|
||||
dependencySourceKey: "tokenSource",
|
||||
missingPrimaryMessage: "LINE channel access token not configured",
|
||||
missingDependentMessage: "LINE channel secret not configured",
|
||||
});
|
||||
|
||||
export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["status"]> =
|
||||
createComputedAccountStatusAdapter<ResolvedLineAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: collectLineStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: hasLineCredentials(account),
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: "webhook",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/litellm-provider",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw LiteLLM provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -52,6 +52,7 @@ export type FileWithThumbnailInfo = {
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_file?: EncryptedFile;
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
|
||||
@@ -172,19 +172,52 @@ describe("sendMessageMatrix media", () => {
|
||||
expect(content.file?.url).toBe("mxc://example/file");
|
||||
});
|
||||
|
||||
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
|
||||
const { client, uploadContent } = makeEncryptedMediaClient();
|
||||
it("encrypts thumbnail via thumbnail_file when room is encrypted", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
const isRoomEncrypted = vi.fn().mockResolvedValue(true);
|
||||
const encryptMedia = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("encrypted-thumb"),
|
||||
file: {
|
||||
key: { kty: "oct", key_ops: ["encrypt", "decrypt"], alg: "A256CTR", k: "tkey", ext: true },
|
||||
iv: "tiv",
|
||||
hashes: { sha256: "thash" },
|
||||
v: "v2",
|
||||
},
|
||||
});
|
||||
(client as { crypto?: object }).crypto = {
|
||||
isRoomEncrypted,
|
||||
encryptMedia,
|
||||
};
|
||||
// Return image metadata so thumbnail generation is triggered (image > 800px)
|
||||
getImageMetadataMock
|
||||
.mockResolvedValueOnce({ width: 1600, height: 1200 })
|
||||
.mockResolvedValueOnce({ width: 800, height: 600 });
|
||||
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
|
||||
.mockResolvedValueOnce({ width: 1920, height: 1080 }) // original image
|
||||
.mockResolvedValueOnce({ width: 800, height: 450 }); // thumbnail
|
||||
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb-bytes"));
|
||||
// Two uploadContent calls: one for the main encrypted image, one for the encrypted thumbnail
|
||||
uploadContent
|
||||
.mockResolvedValueOnce("mxc://example/main")
|
||||
.mockResolvedValueOnce("mxc://example/thumb");
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
expect(uploadContent).toHaveBeenCalledTimes(1);
|
||||
// encryptMedia called twice: once for main media, once for thumbnail
|
||||
expect(isRoomEncrypted).toHaveBeenCalledTimes(1);
|
||||
expect(encryptMedia).toHaveBeenCalledTimes(2);
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
file?: { url?: string };
|
||||
info?: { thumbnail_url?: string; thumbnail_file?: { url?: string } };
|
||||
};
|
||||
// Main media encrypted correctly
|
||||
expect(content.url).toBeUndefined();
|
||||
expect(content.file?.url).toBe("mxc://example/main");
|
||||
// Thumbnail must use thumbnail_file (encrypted), NOT thumbnail_url (unencrypted)
|
||||
expect(content.info?.thumbnail_url).toBeUndefined();
|
||||
expect(content.info?.thumbnail_file?.url).toBe("mxc://example/thumb");
|
||||
});
|
||||
|
||||
it("keeps reply context on voice transcript follow-ups outside threads", async () => {
|
||||
@@ -246,7 +279,7 @@ describe("sendMessageMatrix media", () => {
|
||||
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uploads thumbnail metadata for unencrypted large images", async () => {
|
||||
it("keeps thumbnail_url metadata for unencrypted large images", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
getImageMetadataMock
|
||||
.mockResolvedValueOnce({ width: 1600, height: 1200 })
|
||||
@@ -262,6 +295,7 @@ describe("sendMessageMatrix media", () => {
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
info?: {
|
||||
thumbnail_url?: string;
|
||||
thumbnail_file?: { url?: string };
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
@@ -271,6 +305,7 @@ describe("sendMessageMatrix media", () => {
|
||||
};
|
||||
};
|
||||
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
|
||||
expect(content.info?.thumbnail_file).toBeUndefined();
|
||||
expect(content.info?.thumbnail_info).toMatchObject({
|
||||
w: 800,
|
||||
h: 600,
|
||||
|
||||
@@ -122,10 +122,6 @@ export async function prepareImageInfo(params: {
|
||||
return undefined;
|
||||
}
|
||||
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
||||
if (params.encrypted) {
|
||||
// For E2EE media, avoid uploading plaintext thumbnails.
|
||||
return imageInfo;
|
||||
}
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||
try {
|
||||
@@ -138,12 +134,16 @@ export async function prepareImageInfo(params: {
|
||||
const thumbMeta = await getCore()
|
||||
.media.getImageMetadata(thumbBuffer)
|
||||
.catch(() => null);
|
||||
const thumbUri = await params.client.uploadContent(
|
||||
thumbBuffer,
|
||||
"image/jpeg",
|
||||
"thumbnail.jpg",
|
||||
);
|
||||
imageInfo.thumbnail_url = thumbUri;
|
||||
const result = await uploadMediaWithEncryption(params.client, thumbBuffer, {
|
||||
contentType: "image/jpeg",
|
||||
filename: "thumbnail.jpg",
|
||||
encrypted: params.encrypted === true,
|
||||
});
|
||||
if (result.file) {
|
||||
imageInfo.thumbnail_file = result.file;
|
||||
} else {
|
||||
imageInfo.thumbnail_url = result.url;
|
||||
}
|
||||
if (thumbMeta) {
|
||||
imageInfo.thumbnail_info = {
|
||||
w: thumbMeta.width,
|
||||
@@ -202,6 +202,29 @@ async function uploadFile(
|
||||
return await client.uploadContent(file, params.contentType, params.filename);
|
||||
}
|
||||
|
||||
async function uploadMediaWithEncryption(
|
||||
client: MatrixClient,
|
||||
buffer: Buffer,
|
||||
params: {
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
encrypted: boolean;
|
||||
},
|
||||
): Promise<{ url: string; file?: EncryptedFile }> {
|
||||
if (params.encrypted && client.crypto) {
|
||||
const encrypted = await client.crypto.encryptMedia(buffer);
|
||||
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
||||
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
||||
return {
|
||||
url: mxc,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
const mxc = await uploadFile(client, buffer, params);
|
||||
return { url: mxc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media with optional encryption for E2EE rooms.
|
||||
*/
|
||||
@@ -215,20 +238,9 @@ export async function uploadMediaMaybeEncrypted(
|
||||
},
|
||||
): Promise<{ url: string; file?: EncryptedFile }> {
|
||||
// Check if room is encrypted and crypto is available
|
||||
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
|
||||
|
||||
if (isEncrypted && client.crypto) {
|
||||
// Encrypt the media before uploading
|
||||
const encrypted = await client.crypto.encryptMedia(buffer);
|
||||
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
||||
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
||||
return {
|
||||
url: mxc,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload unencrypted
|
||||
const mxc = await uploadFile(client, buffer, params);
|
||||
return { url: mxc };
|
||||
const isEncrypted = Boolean(client.crypto && (await client.crypto.isRoomEncrypted(roomId)));
|
||||
return await uploadMediaWithEncryption(client, buffer, {
|
||||
...params,
|
||||
encrypted: isEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
|
||||
// channel plugin (and its runtime state) into tests or other shared surfaces.
|
||||
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
|
||||
import type {
|
||||
MattermostAccountConfig,
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
MattermostReplyToMode,
|
||||
} from "../types.js";
|
||||
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
export type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
@@ -30,11 +31,15 @@ export type ResolvedMattermostAccount = {
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
const {
|
||||
listAccountIds: listMattermostAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultMattermostAccountId,
|
||||
} = createAccountListHelpers("mattermost");
|
||||
export { listMattermostAccountIds, resolveDefaultMattermostAccountId };
|
||||
const mattermostAccountHelpers = createAccountListHelpers("mattermost");
|
||||
|
||||
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return mattermostAccountHelpers.listAccountIds(cfg);
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
|
||||
return mattermostAccountHelpers.resolveDefaultAccountId(cfg);
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
type MattermostClient,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
export type MattermostDirectoryParams = {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "../runtime-api.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
|
||||
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
|
||||
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { MattermostInteractiveButtonInput } from "./interactions.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
normalizeProviderId,
|
||||
@@ -6,8 +7,7 @@ import {
|
||||
resolveStoredModelOverride,
|
||||
type ModelsProviderData,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import type { MattermostInteractiveButtonInput } from "./interactions.js";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";
|
||||
const MODELS_PAGE_SIZE = 8;
|
||||
|
||||
@@ -6,13 +6,17 @@ const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
|
||||
const resolveControlCommandGate = vi.hoisted(() => vi.fn());
|
||||
const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
}));
|
||||
vi.mock("./runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./runtime-api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
};
|
||||
});
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostChannel } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import {
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostChannel } from "./client.js";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatType, OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ChatType, OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
|
||||
if (!channelType) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
createDedupeCache,
|
||||
formatInboundFromLabel as formatInboundFromLabelShared,
|
||||
rawDataToString,
|
||||
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
export { createDedupeCache, rawDataToString } from "../runtime-api.js";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export { createDedupeCache, rawDataToString };
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
model?: string;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
listSkillCommandsForAgents,
|
||||
parseStrictPositiveInteger,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
type MattermostClient,
|
||||
} from "./client.js";
|
||||
import {
|
||||
listSkillCommandsForAgents,
|
||||
parseStrictPositiveInteger,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import {
|
||||
DEFAULT_COMMAND_SPECS,
|
||||
isSlashCommandsEnabled,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import WebSocket from "ws";
|
||||
import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js";
|
||||
import { MattermostPostSchema, type MattermostPost } from "./client.js";
|
||||
import { rawDataToString } from "./monitor-helpers.js";
|
||||
import type { ChannelAccountSnapshot, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
export type MattermostEventPayload = {
|
||||
event?: string;
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChatType,
|
||||
OpenClawConfig,
|
||||
ReplyPayload,
|
||||
RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
buildModelsProviderData,
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
createChannelPairingController,
|
||||
createChannelReplyPipeline,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
isDangerousNameMatchingEnabled,
|
||||
registerPluginHttpRoute,
|
||||
resolveControlCommandGate,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveChannelMediaMaxBytes,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type HistoryEntry,
|
||||
} from "../runtime-api.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
|
||||
import {
|
||||
@@ -82,6 +52,36 @@ import {
|
||||
} from "./monitor-websocket.js";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChatType,
|
||||
OpenClawConfig,
|
||||
ReplyPayload,
|
||||
RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
buildModelsProviderData,
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
createChannelPairingController,
|
||||
createChannelReplyPipeline,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
isDangerousNameMatchingEnabled,
|
||||
registerPluginHttpRoute,
|
||||
resolveControlCommandGate,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveChannelMediaMaxBytes,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type HistoryEntry,
|
||||
} from "./runtime-api.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
import { cleanupSlashCommands } from "./slash-commands.js";
|
||||
import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BaseProbeResult } from "../runtime-api.js";
|
||||
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
|
||||
import type { BaseProbeResult } from "./runtime-api.js";
|
||||
|
||||
export type MattermostProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
type MattermostClient,
|
||||
type MattermostFetch,
|
||||
} from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
type Result = { ok: true } | { ok: false; error: string };
|
||||
type ReactionParams = {
|
||||
|
||||
@@ -2,8 +2,12 @@ import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
|
||||
import {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
type OpenClawConfig,
|
||||
type PluginRuntime,
|
||||
type ReplyPayload,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
setInteractionSecret,
|
||||
type MattermostInteractiveButtonInput,
|
||||
} from "./interactions.js";
|
||||
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js";
|
||||
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
|
||||
@@ -39,14 +39,29 @@ const mockState = vi.hoisted(() => ({
|
||||
normalizeMattermostAllowList: vi.fn((value: unknown) => value),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/mattermost", () => ({
|
||||
buildModelsProviderData: mockState.buildModelsProviderData,
|
||||
createReplyPrefixOptions: vi.fn(() => ({})),
|
||||
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
logTypingFailure: vi.fn(),
|
||||
readRequestBodyWithLimit: mockState.readRequestBodyWithLimit,
|
||||
}));
|
||||
vi.mock("./runtime-api.js", () => {
|
||||
return {
|
||||
buildModelsProviderData: mockState.buildModelsProviderData,
|
||||
createChannelReplyPipeline: vi.fn(() => ({
|
||||
onModelSelected: vi.fn(),
|
||||
typingCallbacks: {},
|
||||
})),
|
||||
createDedupeCache: vi.fn(() => ({
|
||||
check: () => false,
|
||||
})),
|
||||
createReplyPrefixOptions: vi.fn(() => ({})),
|
||||
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
logTypingFailure: vi.fn(),
|
||||
formatInboundFromLabel: vi.fn(() => ""),
|
||||
rawDataToString: vi.fn((value: unknown) => String(value ?? "")),
|
||||
readRequestBodyWithLimit: mockState.readRequestBodyWithLimit,
|
||||
resolveThreadSessionKeys: vi.fn((params: { baseSessionKey: string }) => ({
|
||||
sessionKey: params.baseSessionKey,
|
||||
parentSessionKey: undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMattermostRuntime: () => ({
|
||||
@@ -71,12 +86,16 @@ vi.mock("../runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createMattermostClient: mockState.createMattermostClient,
|
||||
fetchMattermostChannel: mockState.fetchMattermostChannel,
|
||||
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
|
||||
sendMattermostTyping: vi.fn(),
|
||||
}));
|
||||
vi.mock("./client.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./client.js")>();
|
||||
return {
|
||||
...actual,
|
||||
createMattermostClient: mockState.createMattermostClient,
|
||||
fetchMattermostChannel: mockState.fetchMattermostChannel,
|
||||
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
|
||||
sendMattermostTyping: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./model-picker.js", () => ({
|
||||
renderMattermostModelSummaryView: vi.fn(),
|
||||
|
||||
@@ -7,16 +7,6 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
|
||||
import {
|
||||
buildModelsProviderData,
|
||||
createChannelReplyPipeline,
|
||||
isRequestBodyLimitError,
|
||||
logTypingFailure,
|
||||
readRequestBodyWithLimit,
|
||||
type OpenClawConfig,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
@@ -37,6 +27,16 @@ import {
|
||||
normalizeMattermostAllowList,
|
||||
} from "./monitor-auth.js";
|
||||
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
|
||||
import {
|
||||
buildModelsProviderData,
|
||||
createChannelReplyPipeline,
|
||||
isRequestBodyLimitError,
|
||||
logTypingFailure,
|
||||
readRequestBodyWithLimit,
|
||||
type OpenClawConfig,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
import {
|
||||
parseSlashCommandPayload,
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import type { MattermostConfig } from "../types.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { OpenClawPluginApi } from "./runtime-api.js";
|
||||
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
|
||||
import { createSlashCommandHttpHandler } from "./slash-http.js";
|
||||
|
||||
@@ -87,8 +87,8 @@ export function activateSlashCommands(params: {
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
triggerMap?: Map<string, string>;
|
||||
api: {
|
||||
cfg: import("../runtime-api.js").OpenClawConfig;
|
||||
runtime: import("../runtime-api.js").RuntimeEnv;
|
||||
cfg: import("./runtime-api.js").OpenClawConfig;
|
||||
runtime: import("./runtime-api.js").RuntimeEnv;
|
||||
};
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
} from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
export type MattermostOpaqueTargetResolution = {
|
||||
kind: "user" | "channel";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/media-understanding-core",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw media understanding runtime package",
|
||||
"type": "module"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.28-beta.1"
|
||||
"openclaw": ">=2026.3.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
|
||||
logWarnMock: vi.fn(),
|
||||
logDebugMock: vi.fn(),
|
||||
logInfoMock: vi.fn(),
|
||||
}));
|
||||
|
||||
type MockChild = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: (signal?: NodeJS.Signals) => void;
|
||||
closeWith: (code?: number | null) => void;
|
||||
};
|
||||
|
||||
function createMockChild(params?: { autoClose?: boolean }): MockChild {
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
const child = new EventEmitter() as MockChild;
|
||||
child.stdout = stdout;
|
||||
child.stderr = stderr;
|
||||
child.closeWith = (code = 0) => {
|
||||
child.emit("close", code);
|
||||
};
|
||||
child.kill = () => {};
|
||||
if (params?.autoClose !== false) {
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", 0);
|
||||
});
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
function emitAndClose(
|
||||
child: MockChild,
|
||||
stream: "stdout" | "stderr",
|
||||
data: string,
|
||||
code: number = 0,
|
||||
) {
|
||||
queueMicrotask(() => {
|
||||
child[stream].emit("data", data);
|
||||
child.closeWith(code);
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("openclaw/plugin-sdk/memory-core-host-engine-foundation")
|
||||
>("openclaw/plugin-sdk/memory-core-host-engine-foundation");
|
||||
return {
|
||||
...actual,
|
||||
createSubsystemLogger: () => {
|
||||
const logger = {
|
||||
warn: logWarnMock,
|
||||
debug: logDebugMock,
|
||||
info: logInfoMock,
|
||||
child: () => logger,
|
||||
};
|
||||
return logger;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { spawn as mockedSpawn } from "node:child_process";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { QmdMemoryManager } from "./qmd-manager.js";
|
||||
|
||||
const spawnMock = mockedSpawn as unknown as Mock;
|
||||
|
||||
describe("QmdMemoryManager slugified path resolution", () => {
|
||||
let tmpRoot: string;
|
||||
let workspaceDir: string;
|
||||
let stateDir: string;
|
||||
let cfg: OpenClawConfig;
|
||||
const agentId = "main";
|
||||
const openManagers = new Set<QmdMemoryManager>();
|
||||
|
||||
function trackManager<T extends QmdMemoryManager | null>(manager: T): T {
|
||||
if (manager) {
|
||||
openManagers.add(manager);
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function createManager(params?: { cfg?: OpenClawConfig }) {
|
||||
const cfgToUse = params?.cfg ?? cfg;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId });
|
||||
const manager = trackManager(
|
||||
await QmdMemoryManager.create({
|
||||
cfg: cfgToUse,
|
||||
agentId,
|
||||
resolved,
|
||||
mode: "status",
|
||||
}),
|
||||
);
|
||||
if (!manager) {
|
||||
throw new Error("manager missing");
|
||||
}
|
||||
return { manager };
|
||||
}
|
||||
|
||||
function installIndexedPathStub(params: {
|
||||
manager: QmdMemoryManager;
|
||||
collection: string;
|
||||
normalizedPath: string;
|
||||
actualPath?: string;
|
||||
exactPaths?: string[];
|
||||
allPaths?: string[];
|
||||
}) {
|
||||
const inner = params.manager as unknown as {
|
||||
db: {
|
||||
prepare: (query: string) => { all: (...args: unknown[]) => unknown };
|
||||
close: () => void;
|
||||
};
|
||||
};
|
||||
inner.db = {
|
||||
prepare: (query: string) => ({
|
||||
all: (...args: unknown[]) => {
|
||||
if (query.includes("collection = ? AND path = ?")) {
|
||||
expect(args).toEqual([params.collection, params.normalizedPath]);
|
||||
return (params.exactPaths ?? []).map((pathValue) => ({ path: pathValue }));
|
||||
}
|
||||
if (query.includes("collection = ? AND active = 1")) {
|
||||
expect(args).toEqual([params.collection]);
|
||||
return (params.allPaths ?? [params.actualPath]).map((pathValue) => ({
|
||||
path: pathValue,
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected sqlite query: ${query}`);
|
||||
},
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
spawnMock.mockReset();
|
||||
spawnMock.mockImplementation(() => createMockChild());
|
||||
logWarnMock.mockClear();
|
||||
logDebugMock.mockClear();
|
||||
logInfoMock.mockClear();
|
||||
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-slugified-"));
|
||||
workspaceDir = path.join(tmpRoot, "workspace");
|
||||
stateDir = path.join(tmpRoot, "state");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
cfg = {
|
||||
agents: {
|
||||
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
Array.from(openManagers, async (manager) => {
|
||||
await manager.close();
|
||||
}),
|
||||
);
|
||||
openManagers.clear();
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
});
|
||||
|
||||
it("maps slugified workspace qmd URIs back to the indexed filesystem path", async () => {
|
||||
const actualRelative = "extra-docs/Category/Sub Category/Topic Name/Topic Name.md";
|
||||
const actualFile = path.join(workspaceDir, actualRelative);
|
||||
await fs.mkdir(path.dirname(actualFile), { recursive: true });
|
||||
await fs.writeFile(actualFile, "line-1\nline-2\nline-3", "utf-8");
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([
|
||||
{
|
||||
file: "qmd://workspace-main/extra-docs/category/sub-category/topic-name/topic-name.md",
|
||||
score: 0.73,
|
||||
snippet: "@@ -2,1\nline-2",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
installIndexedPathStub({
|
||||
manager,
|
||||
collection: "workspace-main",
|
||||
normalizedPath: "extra-docs/category/sub-category/topic-name/topic-name.md",
|
||||
actualPath: actualRelative,
|
||||
});
|
||||
|
||||
const results = await manager.search("line-2", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: actualRelative,
|
||||
startLine: 2,
|
||||
endLine: 2,
|
||||
score: 0.73,
|
||||
snippet: "@@ -2,1\nline-2",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(manager.readFile({ relPath: results[0]!.path })).resolves.toEqual({
|
||||
path: actualRelative,
|
||||
text: "line-1\nline-2\nline-3",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps slugified extra collection qmd URIs back to qmd/<collection>/ paths", async () => {
|
||||
const extraRoot = path.join(tmpRoot, "vault");
|
||||
await fs.mkdir(extraRoot, { recursive: true });
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: extraRoot, pattern: "**/*.md", name: "vault" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const actualRelative = "Topics/Sub Category/Topic Name.md";
|
||||
const actualFile = path.join(extraRoot, actualRelative);
|
||||
await fs.mkdir(path.dirname(actualFile), { recursive: true });
|
||||
await fs.writeFile(actualFile, "vault memory", "utf-8");
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([
|
||||
{
|
||||
file: "qmd://vault-main/topics/sub-category/topic-name.md",
|
||||
score: 0.81,
|
||||
snippet: "@@ -1,1\nvault memory",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ cfg });
|
||||
installIndexedPathStub({
|
||||
manager,
|
||||
collection: "vault-main",
|
||||
normalizedPath: "topics/sub-category/topic-name.md",
|
||||
actualPath: actualRelative,
|
||||
});
|
||||
|
||||
const results = await manager.search("vault memory", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: `qmd/vault-main/${actualRelative}`,
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.81,
|
||||
snippet: "@@ -1,1\nvault memory",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(manager.readFile({ relPath: results[0]!.path })).resolves.toEqual({
|
||||
path: `qmd/vault-main/${actualRelative}`,
|
||||
text: "vault memory",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an exact indexed path over normalized slug recovery", async () => {
|
||||
const exactRelative = "notes/topic-name.md";
|
||||
const slugCollisionRelative = "notes/Topic Name.md";
|
||||
const exactFile = path.join(workspaceDir, exactRelative);
|
||||
const collisionFile = path.join(workspaceDir, slugCollisionRelative);
|
||||
await fs.mkdir(path.dirname(exactFile), { recursive: true });
|
||||
await fs.writeFile(exactFile, "exact slugified path", "utf-8");
|
||||
await fs.writeFile(collisionFile, "mixed case path", "utf-8");
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([
|
||||
{
|
||||
file: "qmd://workspace-main/notes/topic-name.md",
|
||||
score: 0.79,
|
||||
snippet: "@@ -1,1\nexact slugified path",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
installIndexedPathStub({
|
||||
manager,
|
||||
collection: "workspace-main",
|
||||
normalizedPath: exactRelative,
|
||||
exactPaths: [exactRelative],
|
||||
allPaths: [exactRelative, slugCollisionRelative],
|
||||
});
|
||||
|
||||
const results = await manager.search("exact slugified path", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: exactRelative,
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.79,
|
||||
snippet: "@@ -1,1\nexact slugified path",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(manager.readFile({ relPath: results[0]!.path })).resolves.toEqual({
|
||||
path: exactRelative,
|
||||
text: "exact slugified path",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2025,6 +2025,140 @@ describe("QmdMemoryManager", () => {
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("runs periodic embed maintenance even when regular update scheduling is disabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
searchMode: "query",
|
||||
update: {
|
||||
interval: "0s",
|
||||
debounceMs: 0,
|
||||
onBoot: false,
|
||||
embedInterval: "5m",
|
||||
},
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
|
||||
const commandCallsBefore = spawnMock.mock.calls.filter((call: unknown[]) => {
|
||||
const args = call[1] as string[];
|
||||
return args[0] === "update" || args[0] === "embed";
|
||||
});
|
||||
expect(commandCallsBefore).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||
|
||||
const commandCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
|
||||
expect(commandCalls).toEqual([["update"], ["embed"]]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("runs periodic embed maintenance when embed cadence is faster than update cadence", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
searchMode: "query",
|
||||
update: {
|
||||
interval: "20m",
|
||||
debounceMs: 0,
|
||||
onBoot: false,
|
||||
embedInterval: "5m",
|
||||
},
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||
|
||||
const commandCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
|
||||
expect(commandCalls).toEqual([["update"], ["embed"]]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("does not schedule redundant embed maintenance when regular updates are already more frequent", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
searchMode: "query",
|
||||
update: {
|
||||
interval: "5m",
|
||||
debounceMs: 0,
|
||||
onBoot: false,
|
||||
embedInterval: "20m",
|
||||
},
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6 * 60_000);
|
||||
|
||||
const commandCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
|
||||
expect(commandCalls).toEqual([["update"], ["embed"]]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("does not arm periodic embed maintenance in search mode", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
searchMode: "search",
|
||||
update: {
|
||||
interval: "0s",
|
||||
debounceMs: 0,
|
||||
onBoot: false,
|
||||
embedInterval: "5m",
|
||||
},
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||
|
||||
const commandCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
|
||||
expect(commandCalls).toEqual([]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("skips qmd embed in search mode even for forced sync", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
|
||||
@@ -184,6 +184,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS;
|
||||
private readonly sessionExporter: SessionExporterConfig | null;
|
||||
private updateTimer: NodeJS.Timeout | null = null;
|
||||
private embedTimer: NodeJS.Timeout | null = null;
|
||||
private pendingUpdate: Promise<void> | null = null;
|
||||
private queuedForcedUpdate: Promise<void> | null = null;
|
||||
private queuedForcedRuns = 0;
|
||||
@@ -290,6 +291,13 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
});
|
||||
}, this.qmd.update.intervalMs);
|
||||
}
|
||||
if (this.shouldScheduleEmbedTimer()) {
|
||||
this.embedTimer = setInterval(() => {
|
||||
void this.runUpdate("embed-interval").catch((err) => {
|
||||
log.warn(`qmd embed interval update failed (${String(err)})`);
|
||||
});
|
||||
}, this.qmd.update.embedIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private bootstrapCollections(): void {
|
||||
@@ -985,6 +993,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
clearInterval(this.updateTimer);
|
||||
this.updateTimer = null;
|
||||
}
|
||||
if (this.embedTimer) {
|
||||
clearInterval(this.embedTimer);
|
||||
this.embedTimer = null;
|
||||
}
|
||||
this.queuedForcedRuns = 0;
|
||||
await this.pendingUpdate?.catch(() => undefined);
|
||||
await this.queuedForcedUpdate?.catch(() => undefined);
|
||||
@@ -1111,6 +1123,18 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
);
|
||||
}
|
||||
|
||||
private shouldScheduleEmbedTimer(): boolean {
|
||||
if (this.qmd.searchMode === "search") {
|
||||
return false;
|
||||
}
|
||||
const embedIntervalMs = this.qmd.update.embedIntervalMs;
|
||||
if (embedIntervalMs <= 0) {
|
||||
return false;
|
||||
}
|
||||
const updateIntervalMs = this.qmd.update.intervalMs;
|
||||
return updateIntervalMs <= 0 || updateIntervalMs > embedIntervalMs;
|
||||
}
|
||||
|
||||
private noteEmbedFailure(reason: string, err: unknown): void {
|
||||
this.embedFailureCount += 1;
|
||||
const delayMs = Math.min(
|
||||
@@ -1548,6 +1572,13 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (!hints.preferredCollection || !hints.preferredFile) {
|
||||
return null;
|
||||
}
|
||||
const indexedLocation = this.resolveIndexedDocLocationFromHint(
|
||||
hints.preferredCollection,
|
||||
hints.preferredFile,
|
||||
);
|
||||
if (indexedLocation) {
|
||||
return indexedLocation;
|
||||
}
|
||||
const collectionRelativePath = this.toCollectionRelativePath(
|
||||
hints.preferredCollection,
|
||||
hints.preferredFile,
|
||||
@@ -1558,6 +1589,45 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
return this.toDocLocation(hints.preferredCollection, collectionRelativePath);
|
||||
}
|
||||
|
||||
private resolveIndexedDocLocationFromHint(
|
||||
collection: string,
|
||||
preferredFile: string,
|
||||
): { rel: string; abs: string; source: MemorySource } | null {
|
||||
const trimmedCollection = collection.trim();
|
||||
const trimmedFile = preferredFile.trim();
|
||||
if (!trimmedCollection || !trimmedFile) {
|
||||
return null;
|
||||
}
|
||||
const exactPath = path.normalize(trimmedFile).replace(/\\/g, "/");
|
||||
let rows: Array<{ path: string }> = [];
|
||||
try {
|
||||
const db = this.ensureDb();
|
||||
const exactRows = db
|
||||
.prepare("SELECT path FROM documents WHERE collection = ? AND path = ? AND active = 1")
|
||||
.all(trimmedCollection, exactPath) as Array<{ path: string }>;
|
||||
if (exactRows.length > 0) {
|
||||
return this.toDocLocation(trimmedCollection, exactRows[0].path);
|
||||
}
|
||||
rows = db
|
||||
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1")
|
||||
.all(trimmedCollection) as Array<{ path: string }>;
|
||||
} catch (err) {
|
||||
if (this.isSqliteBusyError(err)) {
|
||||
log.debug(`qmd index is busy while resolving hinted path: ${String(err)}`);
|
||||
throw this.createQmdBusyError(err);
|
||||
}
|
||||
// Hint-based lookup is best effort. Fall back to the raw hinted path when
|
||||
// the index is unavailable or still warming.
|
||||
log.debug(`qmd index hint lookup skipped: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
const matches = rows.filter((row) => this.matchesPreferredFileHint(row.path, trimmedFile));
|
||||
if (matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return this.toDocLocation(trimmedCollection, matches[0].path);
|
||||
}
|
||||
|
||||
private normalizeDocHints(hints?: { preferredCollection?: string; preferredFile?: string }): {
|
||||
preferredCollection?: string;
|
||||
preferredFile?: string;
|
||||
@@ -1637,10 +1707,8 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
}
|
||||
if (hints?.preferredFile) {
|
||||
const preferred = path.normalize(hints.preferredFile);
|
||||
for (const row of rows) {
|
||||
const rowPath = path.normalize(row.path);
|
||||
if (rowPath !== preferred && !rowPath.endsWith(path.sep + preferred)) {
|
||||
if (!this.matchesPreferredFileHint(row.path, hints.preferredFile)) {
|
||||
continue;
|
||||
}
|
||||
const location = this.toDocLocation(row.collection, row.path);
|
||||
@@ -1658,6 +1726,58 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
private matchesPreferredFileHint(rowPath: string, preferredFile: string): boolean {
|
||||
const preferred = path.normalize(preferredFile).replace(/\\/g, "/");
|
||||
const normalizedRowPath = path.normalize(rowPath).replace(/\\/g, "/");
|
||||
if (normalizedRowPath === preferred || normalizedRowPath.endsWith(`/${preferred}`)) {
|
||||
return true;
|
||||
}
|
||||
const normalizedPreferredLookup = this.normalizeQmdLookupPath(preferredFile);
|
||||
if (!normalizedPreferredLookup) {
|
||||
return false;
|
||||
}
|
||||
const normalizedRowLookup = this.normalizeQmdLookupPath(rowPath);
|
||||
return (
|
||||
normalizedRowLookup === normalizedPreferredLookup ||
|
||||
normalizedRowLookup.endsWith(`/${normalizedPreferredLookup}`)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeQmdLookupPath(filePath: string): string {
|
||||
return filePath
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter((segment) => segment.length > 0 && segment !== ".")
|
||||
.map((segment) => this.normalizeQmdLookupSegment(segment))
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
private normalizeQmdLookupSegment(segment: string): string {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "." || trimmed === "..") {
|
||||
return trimmed;
|
||||
}
|
||||
const parsed = path.posix.parse(trimmed);
|
||||
const normalizePart = (value: string): string =>
|
||||
value
|
||||
.normalize("NFKD")
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const normalizedName = normalizePart(parsed.name);
|
||||
const normalizedExt = parsed.ext
|
||||
.normalize("NFKD")
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}.]+/gu, "");
|
||||
const fallbackName = parsed.name.normalize("NFKD").toLowerCase().replace(/\s+/g, "-").trim();
|
||||
return `${normalizedName || fallbackName || "file"}${normalizedExt}`;
|
||||
}
|
||||
|
||||
private extractSnippetLines(snippet: string): { startLine: number; endLine: number } {
|
||||
const match = SNIPPET_HEADER_RE.exec(snippet);
|
||||
if (match) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.3.28-beta.1",
|
||||
"version": "2026.3.28",
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user