Merge branch 'main' into docs/zh-cn-onboarding-sync
This commit is contained in:
commit
6f6e24ed43
18
.github/workflows/install-smoke.yml
vendored
18
.github/workflows/install-smoke.yml
vendored
@ -62,9 +62,9 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
# This smoke validates that the build-arg path preinstalls selected
|
||||
# extension deps and that matrix plugin discovery stays healthy in the
|
||||
# final runtime image.
|
||||
# This smoke validates that the build-arg path preinstalls the matrix
|
||||
# runtime deps declared by the plugin and that matrix discovery stays
|
||||
# healthy in the final runtime image.
|
||||
- name: Build extension Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
@ -84,9 +84,17 @@ jobs:
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
const Module = require(\"node:module\");
|
||||
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
|
||||
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
|
||||
requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\");
|
||||
requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\");
|
||||
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
|
||||
if (runtimeDeps.length === 0) {
|
||||
throw new Error(
|
||||
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
|
||||
);
|
||||
}
|
||||
for (const dep of runtimeDeps) {
|
||||
requireFromMatrix.resolve(dep);
|
||||
}
|
||||
const { spawnSync } = require(\"node:child_process\");
|
||||
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
|
||||
if (run.status !== 0) {
|
||||
|
||||
@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
|
||||
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
|
||||
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
|
||||
- Contracts/Matrix: validate Matrix session binding coverage through the real manager, expose the manager on the Matrix runtime API, and let tests pass an explicit state directory for isolated contract setup. (#50369) thanks @ChroniCat.
|
||||
|
||||
### Fixes
|
||||
|
||||
@ -161,6 +162,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
|
||||
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
|
||||
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
|
||||
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
|
||||
let env: [String: String]
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowAlwaysPatterns: [String]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
|
||||
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
|
||||
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
||||
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
|
||||
command: command,
|
||||
rawCommand: rawCommand)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
rawCommand: allowlistRawCommand,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowlistMatches = security == .allowlist
|
||||
@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
|
||||
env: env,
|
||||
resolution: allowlistResolutions.first,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowAlwaysPatterns: allowAlwaysPatterns,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||
|
||||
@ -378,7 +378,7 @@ private enum ExecHostExecutor {
|
||||
let context = await self.buildContext(
|
||||
request: request,
|
||||
command: validatedRequest.command,
|
||||
rawCommand: validatedRequest.displayCommand)
|
||||
rawCommand: validatedRequest.evaluationRawCommand)
|
||||
|
||||
switch ExecHostRequestEvaluator.evaluate(
|
||||
context: context,
|
||||
@ -476,13 +476,7 @@ private enum ExecHostExecutor {
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in context.allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: candidate)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
for pattern in context.allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@ -52,6 +52,23 @@ struct ExecCommandResolution {
|
||||
return [resolution]
|
||||
}
|
||||
|
||||
static func resolveAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [String]
|
||||
{
|
||||
var patterns: [String] = []
|
||||
var seen = Set<String>()
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: 0,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return patterns
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
@ -101,6 +118,115 @@ struct ExecCommandResolution {
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func collectAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
depth: Int,
|
||||
patterns: inout [String],
|
||||
seen: inout Set<String>)
|
||||
{
|
||||
guard depth < 3, !command.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
ExecCommandToken.basenameLower(token0) == "env",
|
||||
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
||||
!envUnwrapped.isEmpty
|
||||
{
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: envUnwrapped,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: shellMultiplexer,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
else {
|
||||
return
|
||||
}
|
||||
for segment in segments {
|
||||
let tokens = self.tokenizeShellWords(segment)
|
||||
guard !tokens.isEmpty else {
|
||||
continue
|
||||
}
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: tokens,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||
seen.insert(pattern).inserted
|
||||
else {
|
||||
return
|
||||
}
|
||||
patterns.append(pattern)
|
||||
}
|
||||
|
||||
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
||||
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let wrapper = ExecCommandToken.basenameLower(token0)
|
||||
guard wrapper == "busybox" || wrapper == "toybox" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var appletIndex = 1
|
||||
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
||||
appletIndex += 1
|
||||
}
|
||||
guard appletIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !applet.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalizedApplet = ExecCommandToken.basenameLower(applet)
|
||||
let shellWrappers = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"dash",
|
||||
"fish",
|
||||
"ksh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"sh",
|
||||
"zsh",
|
||||
])
|
||||
guard shellWrappers.contains(normalizedApplet) else {
|
||||
return nil
|
||||
}
|
||||
return Array(argv[appletIndex...])
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@ -12,14 +12,24 @@ enum ExecCommandToken {
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
struct UnwrapResult {
|
||||
let command: [String]
|
||||
let usesModifiers: Bool
|
||||
}
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func unwrap(_ command: [String]) -> [String]? {
|
||||
self.unwrapWithMetadata(command)?.command
|
||||
}
|
||||
|
||||
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
var usesModifiers = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
guard !expectsOptionValue, idx < command.count else { return nil }
|
||||
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
||||
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
if unwrapped.usesModifiers {
|
||||
break
|
||||
}
|
||||
current = unwrapped.command
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
|
||||
@ -3,6 +3,7 @@ import Foundation
|
||||
struct ExecHostValidatedRequest {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ExecHostPolicyDecision {
|
||||
@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
|
||||
rawCommand: request.rawCommand)
|
||||
switch validatedCommand {
|
||||
case let .ok(resolved):
|
||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||
return .success(ExecHostValidatedRequest(
|
||||
command: command,
|
||||
displayCommand: resolved.displayCommand,
|
||||
evaluationRawCommand: resolved.evaluationRawCommand))
|
||||
case let .invalid(message):
|
||||
return .failure(
|
||||
ExecHostError(
|
||||
|
||||
@ -3,6 +3,7 @@ import Foundation
|
||||
enum ExecSystemRunCommandValidator {
|
||||
struct ResolvedCommand {
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ValidationResult {
|
||||
@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
|
||||
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
let formattedArgv = ExecCommandFormatter.displayString(for: command)
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
ExecCommandFormatter.displayString(for: command)
|
||||
nil
|
||||
}
|
||||
|
||||
if let raw = normalizedRaw, raw != inferred {
|
||||
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
|
||||
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
|
||||
}
|
||||
|
||||
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
|
||||
return .ok(ResolvedCommand(
|
||||
displayCommand: formattedArgv,
|
||||
evaluationRawCommand: self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)))
|
||||
}
|
||||
|
||||
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
|
||||
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
|
||||
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)
|
||||
}
|
||||
|
||||
private static func normalizeRaw(_ rawCommand: String?) -> String? {
|
||||
@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func allowlistEvaluationRawCommand(
|
||||
normalizedRaw: String?,
|
||||
shellIsWrapper: Bool,
|
||||
previewCommand: String?) -> String?
|
||||
{
|
||||
guard shellIsWrapper else {
|
||||
return normalizedRaw
|
||||
}
|
||||
guard let normalizedRaw else {
|
||||
return nil
|
||||
}
|
||||
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||
}
|
||||
|
||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
||||
let base = ExecCommandToken.basenameLower(token)
|
||||
if base.hasSuffix(".exe") {
|
||||
|
||||
@ -507,8 +507,7 @@ actor MacNodeRuntime {
|
||||
persistAllowlist: persistAllowlist,
|
||||
security: evaluation.security,
|
||||
agentId: evaluation.agentId,
|
||||
command: command,
|
||||
allowlistResolutions: evaluation.allowlistResolutions)
|
||||
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
|
||||
|
||||
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
@ -795,15 +794,11 @@ extension MacNodeRuntime {
|
||||
persistAllowlist: Bool,
|
||||
security: ExecSecurity,
|
||||
agentId: String?,
|
||||
command: [String],
|
||||
allowlistResolutions: [ExecCommandResolution])
|
||||
allowAlwaysPatterns: [String])
|
||||
{
|
||||
guard persistAllowlist, security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
|
||||
continue
|
||||
}
|
||||
for pattern in allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ import Testing
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@ -240,7 +240,7 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
||||
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
|
||||
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@ -248,11 +248,11 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
||||
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@ -260,8 +260,33 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
cwd: nil,
|
||||
envOverrides: ["PATH": "/usr/bin:/bin"],
|
||||
agentId: nil)
|
||||
|
||||
#expect(evaluation.displayCommand == rawCommand)
|
||||
#expect(evaluation.allowlistResolutions.count == 1)
|
||||
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
|
||||
@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstWriteDate = try Self.modificationDate(at: url)
|
||||
let firstIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondWriteDate = try Self.modificationDate(at: url)
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
#expect(firstWriteDate == secondWriteDate)
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static func modificationDate(at url: URL) throws -> Date {
|
||||
private static func fileIdentity(at url: URL) throws -> Int {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
struct MissingDateError: Error {}
|
||||
throw MissingDateError()
|
||||
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||
struct MissingIdentifierError: Error {}
|
||||
throw MissingIdentifierError()
|
||||
}
|
||||
return date
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
|
||||
env: [:],
|
||||
resolution: nil,
|
||||
allowlistResolutions: [],
|
||||
allowAlwaysPatterns: [],
|
||||
allowlistMatches: [],
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: nil,
|
||||
|
||||
@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||
let fixtureURL = try self.findContractFixtureURL()
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
|
||||
@ -372,7 +372,7 @@ Planned improvement:
|
||||
|
||||
## Automatic verification notices
|
||||
|
||||
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
|
||||
Matrix now posts verification lifecycle notices directly into the strict DM verification room as `m.notice` messages.
|
||||
That includes:
|
||||
|
||||
- verification request notices
|
||||
@ -381,7 +381,8 @@ That includes:
|
||||
- SAS details (emoji and decimal) when available
|
||||
|
||||
Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw.
|
||||
When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side.
|
||||
For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side.
|
||||
For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally.
|
||||
You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification.
|
||||
|
||||
OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending.
|
||||
|
||||
@ -767,6 +767,14 @@
|
||||
"source": "/gcp",
|
||||
"destination": "/install/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/azure",
|
||||
"destination": "/install/azure"
|
||||
},
|
||||
{
|
||||
"source": "/install/azure/azure",
|
||||
"destination": "/install/azure"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/fly",
|
||||
"destination": "/install/fly"
|
||||
@ -779,6 +787,10 @@
|
||||
"source": "/platforms/gcp",
|
||||
"destination": "/install/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/azure",
|
||||
"destination": "/install/azure"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/macos-vm",
|
||||
"destination": "/install/macos-vm"
|
||||
@ -872,6 +884,7 @@
|
||||
"install/fly",
|
||||
"install/hetzner",
|
||||
"install/gcp",
|
||||
"install/azure",
|
||||
"install/macos-vm",
|
||||
"install/exe-dev",
|
||||
"install/railway",
|
||||
|
||||
169
docs/install/azure.md
Normal file
169
docs/install/azure.md
Normal file
@ -0,0 +1,169 @@
|
||||
---
|
||||
summary: "Run OpenClaw Gateway 24/7 on an Azure Linux VM with durable state"
|
||||
read_when:
|
||||
- You want OpenClaw running 24/7 on Azure with Network Security Group hardening
|
||||
- You want a production-grade, always-on OpenClaw Gateway on your own Azure Linux VM
|
||||
- You want secure administration with Azure Bastion SSH
|
||||
- You want repeatable deployments with Azure Resource Manager templates
|
||||
title: "Azure"
|
||||
---
|
||||
|
||||
# OpenClaw on Azure Linux VM
|
||||
|
||||
This guide sets up an Azure Linux VM, applies Network Security Group (NSG) hardening, configures Azure Bastion (managed Azure SSH entry point), and installs OpenClaw.
|
||||
|
||||
## What you’ll do
|
||||
|
||||
- Deploy Azure compute and network resources with Azure Resource Manager (ARM) templates
|
||||
- Apply Azure Network Security Group (NSG) rules so VM SSH is allowed only from Azure Bastion
|
||||
- Use Azure Bastion for SSH access
|
||||
- Install OpenClaw with the installer script
|
||||
- Verify the Gateway
|
||||
|
||||
## Before you start
|
||||
|
||||
You’ll need:
|
||||
|
||||
- An Azure subscription with permission to create compute and network resources
|
||||
- Azure CLI installed (see [Azure CLI install steps](https://learn.microsoft.com/cli/azure/install-azure-cli) if needed)
|
||||
|
||||
## 1) Sign in to Azure CLI
|
||||
|
||||
```bash
|
||||
az login # Sign in and select your Azure subscription
|
||||
az extension add -n ssh # Extension required for Azure Bastion SSH management
|
||||
```
|
||||
|
||||
## 2) Register required resource providers (one-time)
|
||||
|
||||
```bash
|
||||
az provider register --namespace Microsoft.Compute
|
||||
az provider register --namespace Microsoft.Network
|
||||
```
|
||||
|
||||
Verify Azure resource provider registration. Wait until both show `Registered`.
|
||||
|
||||
```bash
|
||||
az provider show --namespace Microsoft.Compute --query registrationState -o tsv
|
||||
az provider show --namespace Microsoft.Network --query registrationState -o tsv
|
||||
```
|
||||
|
||||
## 3) Set deployment variables
|
||||
|
||||
```bash
|
||||
RG="rg-openclaw"
|
||||
LOCATION="westus2"
|
||||
TEMPLATE_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.json"
|
||||
PARAMS_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.parameters.json"
|
||||
```
|
||||
|
||||
## 4) Select SSH key
|
||||
|
||||
Use your existing public key if you have one:
|
||||
|
||||
```bash
|
||||
SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"
|
||||
```
|
||||
|
||||
If you don’t have an SSH key yet, run the following:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519 -C "you@example.com"
|
||||
SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"
|
||||
```
|
||||
|
||||
## 5) Select VM size and OS disk size
|
||||
|
||||
Set VM and disk sizing variables:
|
||||
|
||||
```bash
|
||||
VM_SIZE="Standard_B2as_v2"
|
||||
OS_DISK_SIZE_GB=64
|
||||
```
|
||||
|
||||
Choose a VM size and OS disk size that are available in your Azure subscription/region and matches your workload:
|
||||
|
||||
- Start smaller for light usage and scale up later
|
||||
- Use more vCPU/RAM/OS disk size for heavier automation, more channels, or larger model/tool workloads
|
||||
- If a VM size is unavailable in your region or subscription quota, pick the closest available SKU
|
||||
|
||||
List VM sizes available in your target region:
|
||||
|
||||
```bash
|
||||
az vm list-skus --location "${LOCATION}" --resource-type virtualMachines -o table
|
||||
```
|
||||
|
||||
Check your current VM vCPU and OS disk size usage/quota:
|
||||
|
||||
```bash
|
||||
az vm list-usage --location "${LOCATION}" -o table
|
||||
```
|
||||
|
||||
## 6) Create the resource group
|
||||
|
||||
```bash
|
||||
az group create -n "${RG}" -l "${LOCATION}"
|
||||
```
|
||||
|
||||
## 7) Deploy resources
|
||||
|
||||
This command applies your selected SSH key, VM size, and OS disk size.
|
||||
|
||||
```bash
|
||||
az deployment group create \
|
||||
-g "${RG}" \
|
||||
--template-uri "${TEMPLATE_URI}" \
|
||||
--parameters "${PARAMS_URI}" \
|
||||
--parameters location="${LOCATION}" \
|
||||
--parameters vmSize="${VM_SIZE}" \
|
||||
--parameters osDiskSizeGb="${OS_DISK_SIZE_GB}" \
|
||||
--parameters sshPublicKey="${SSH_PUB_KEY}"
|
||||
```
|
||||
|
||||
## 8) SSH into the VM through Azure Bastion
|
||||
|
||||
```bash
|
||||
RG="rg-openclaw"
|
||||
VM_NAME="vm-openclaw"
|
||||
BASTION_NAME="bas-openclaw"
|
||||
ADMIN_USERNAME="openclaw"
|
||||
VM_ID="$(az vm show -g "${RG}" -n "${VM_NAME}" --query id -o tsv)"
|
||||
|
||||
az network bastion ssh \
|
||||
--name "${BASTION_NAME}" \
|
||||
--resource-group "${RG}" \
|
||||
--target-resource-id "${VM_ID}" \
|
||||
--auth-type ssh-key \
|
||||
--username "${ADMIN_USERNAME}" \
|
||||
--ssh-key ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
## 9) Install OpenClaw (in the VM shell)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openclaw.ai/install.sh -o /tmp/openclaw-install.sh
|
||||
bash /tmp/openclaw-install.sh
|
||||
rm -f /tmp/openclaw-install.sh
|
||||
openclaw --version
|
||||
```
|
||||
|
||||
The installer script handles Node detection/installation and runs onboarding by default.
|
||||
|
||||
## 10) Verify the Gateway
|
||||
|
||||
After onboarding completes:
|
||||
|
||||
```bash
|
||||
openclaw gateway status
|
||||
```
|
||||
|
||||
Most enterprise Azure teams already have GitHub Copilot licenses. If that is your case, we recommend choosing the GitHub Copilot provider in the OpenClaw onboarding wizard. See [GitHub Copilot provider](/providers/github-copilot).
|
||||
|
||||
The included ARM template uses Ubuntu image `version: "latest"` for convenience. If you need reproducible builds, pin a specific image version in `infra/azure/templates/azuredeploy.json` (you can list versions with `az vm image list --publisher Canonical --offer ubuntu-24_04-lts --sku server --all -o table`).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Set up messaging channels: [Channels](/channels)
|
||||
- Pair local devices as nodes: [Nodes](/nodes)
|
||||
- Configure the Gateway: [Gateway configuration](/gateway/configuration)
|
||||
- For more details on OpenClaw Azure deployment with the GitHub Copilot model provider: [OpenClaw on Azure with GitHub Copilot](https://github.com/johnsonshi/openclaw-azure-github-copilot)
|
||||
@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
|
||||
`- Failed creating a Matrix migration snapshot before repair: ...`
|
||||
|
||||
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
|
||||
|
||||
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
|
||||
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
|
||||
- What to do: run `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
|
||||
|
||||
`Failed inspecting legacy Matrix encrypted state for account "...": ...`
|
||||
`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...`
|
||||
|
||||
- Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery.
|
||||
- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
|
||||
|
||||
@ -29,6 +29,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
- Fly.io: [Fly.io](/install/fly)
|
||||
- Hetzner (Docker): [Hetzner](/install/hetzner)
|
||||
- GCP (Compute Engine): [GCP](/install/gcp)
|
||||
- Azure (Linux VM): [Azure](/install/azure)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/install/exe-dev)
|
||||
|
||||
## Common links
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "VPS hosting hub for OpenClaw (Oracle/Fly/Hetzner/GCP/exe.dev)"
|
||||
summary: "VPS hosting hub for OpenClaw (Oracle/Fly/Hetzner/GCP/Azure/exe.dev)"
|
||||
read_when:
|
||||
- You want to run the Gateway in the cloud
|
||||
- You need a quick map of VPS/hosting guides
|
||||
@ -19,6 +19,7 @@ deployments work at a high level.
|
||||
- **Fly.io**: [Fly.io](/install/fly)
|
||||
- **Hetzner (Docker)**: [Hetzner](/install/hetzner)
|
||||
- **GCP (Compute Engine)**: [GCP](/install/gcp)
|
||||
- **Azure (Linux VM)**: [Azure](/install/azure)
|
||||
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/install/exe-dev)
|
||||
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
|
||||
[https://x.com/techfrenAJ/status/2014934471095812547](https://x.com/techfrenAJ/status/2014934471095812547)
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/discord-core";
|
||||
import {
|
||||
mergeDiscordAccountConfig,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccountConfig,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
type OpenClawConfig,
|
||||
type DiscordAccountConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";
|
||||
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import {
|
||||
createAccountActionGate,
|
||||
createAccountListHelpers,
|
||||
normalizeAccountId,
|
||||
resolveAccountEntry,
|
||||
type OpenClawConfig,
|
||||
type DiscordAccountConfig,
|
||||
type DiscordActionConfig,
|
||||
} from "./runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/account-helpers";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type {
|
||||
DiscordAccountConfig,
|
||||
DiscordActionConfig,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveDiscordToken } from "./token.js";
|
||||
|
||||
export type ResolvedDiscordAccount = {
|
||||
|
||||
@ -15,6 +15,9 @@ export {
|
||||
resolvePollMaxSelections,
|
||||
type ActionGate,
|
||||
type ChannelPlugin,
|
||||
type DiscordAccountConfig,
|
||||
type DiscordActionConfig,
|
||||
type DiscordConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
|
||||
@ -42,8 +45,6 @@ export type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
export type { DiscordConfig } from "openclaw/plugin-sdk/discord";
|
||||
export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
|
||||
export {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/googlechat.js";
|
||||
export * from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
export {
|
||||
createMatrixThreadBindingManager,
|
||||
getMatrixThreadBindingManager,
|
||||
resetMatrixThreadBindingsForTests,
|
||||
} from "./src/matrix/thread-bindings.js";
|
||||
export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js";
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setMatrixRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
@ -14,6 +16,20 @@ describe("matrix plugin registration", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads the matrix runtime api through Jiti", () => {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
interopDefault: true,
|
||||
tryNative: false,
|
||||
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
|
||||
});
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
|
||||
|
||||
expect(jiti(runtimeApiPath)).toMatchObject({
|
||||
requiresExplicitMatrixDefaultAccount: expect.any(Function),
|
||||
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the channel without bootstrapping crypto runtime", () => {
|
||||
const runtime = {} as never;
|
||||
matrixPlugin.register({
|
||||
|
||||
@ -1,3 +1,14 @@
|
||||
export * from "openclaw/plugin-sdk/matrix";
|
||||
export * from "./src/auth-precedence.js";
|
||||
export * from "./helper-api.js";
|
||||
export {
|
||||
findMatrixAccountEntry,
|
||||
hashMatrixAccessToken,
|
||||
listMatrixEnvAccountIds,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixCredentialsFilename,
|
||||
resolveMatrixEnvAccountToken,
|
||||
resolveMatrixHomeserverKey,
|
||||
resolveMatrixLegacyFlatStoreRoot,
|
||||
sanitizeMatrixPathSegment,
|
||||
} from "./helper-api.js";
|
||||
|
||||
@ -59,7 +59,7 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
} as never) ?? { actions: [] };
|
||||
const actions = discovery.actions;
|
||||
|
||||
expect(actions).toContain("poll");
|
||||
@ -74,7 +74,7 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
} as never) ?? { actions: [], schema: null };
|
||||
const actions = discovery.actions;
|
||||
const properties =
|
||||
(discovery.schema as { properties?: Record<string, unknown> } | null)?.properties ?? {};
|
||||
@ -87,64 +87,66 @@ describe("matrixMessageActions", () => {
|
||||
});
|
||||
|
||||
it("hides gated actions when the default Matrix account disables them", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
const actions =
|
||||
matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
} as CoreConfig,
|
||||
} as never)?.actions ?? [];
|
||||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
const actions =
|
||||
matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
} as CoreConfig,
|
||||
} as never)?.actions ?? [];
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@ -2,11 +2,13 @@ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./d
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
export const matrixChannelRuntime = {
|
||||
listMatrixDirectoryGroupsLive,
|
||||
listMatrixDirectoryPeersLive,
|
||||
matrixOutbound,
|
||||
probeMatrix,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixTargets,
|
||||
|
||||
@ -15,8 +15,8 @@ import {
|
||||
createTextPairingAdapter,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
@ -47,7 +47,6 @@ import {
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
import { matrixSetupWizard } from "./setup-surface.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
@ -190,7 +189,6 @@ function matchMatrixAcpConversation(params: {
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
meta,
|
||||
setupWizard: matrixSetupWizard,
|
||||
pairing: createTextPairingAdapter({
|
||||
idLabel: "matrixUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
|
||||
@ -521,7 +521,9 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const jsonOutput = console.log.mock.calls.at(-1)?.[0];
|
||||
const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(
|
||||
-1,
|
||||
)?.[0];
|
||||
expect(typeof jsonOutput).toBe("string");
|
||||
expect(JSON.parse(String(jsonOutput))).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
resolveMatrixAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: () => null,
|
||||
credentialsMatchConfig: () => false,
|
||||
}));
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
|
||||
import { resolveMatrixConfigForAccount } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
|
||||
|
||||
/** Merge account config with top-level defaults, preserving nested objects. */
|
||||
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
|
||||
|
||||
@ -9,16 +9,20 @@ import {
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
import * as credentialsModule from "./credentials.js";
|
||||
import * as credentialsReadModule from "./credentials-read.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
saveMatrixCredentials: saveMatrixCredentialsMock,
|
||||
credentialsMatchConfig: vi.fn(() => false),
|
||||
touchMatrixCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./credentials-write.runtime.js", () => ({
|
||||
saveMatrixCredentials: saveMatrixCredentialsMock,
|
||||
touchMatrixCredentials: touchMatrixCredentialsMock,
|
||||
}));
|
||||
|
||||
describe("resolveMatrixConfig", () => {
|
||||
@ -414,14 +418,14 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses cached matching credentials when access token is not configured", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@ -464,13 +468,13 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("falls back to config deviceId when cached credentials are missing it", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@ -533,8 +537,8 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses named-account password auth instead of inheriting the base access token", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
access_token: "ops-token",
|
||||
user_id: "@ops:example.org",
|
||||
@ -615,13 +619,13 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
listNormalizedMatrixAccountIds,
|
||||
} from "../account-config.js";
|
||||
import { resolveMatrixConfigFieldPath } from "../config-update.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "../credentials-read.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
@ -338,13 +339,11 @@ export async function resolveMatrixAuth(params?: {
|
||||
}): Promise<MatrixAuth> {
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
|
||||
const homeserver = validateMatrixHomeserverUrl(resolved.homeserver);
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
let credentialsWriter: typeof import("../credentials-write.runtime.js") | undefined;
|
||||
const loadCredentialsWriter = async () => {
|
||||
credentialsWriter ??= await import("../credentials-write.runtime.js");
|
||||
return credentialsWriter;
|
||||
};
|
||||
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
@ -391,6 +390,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
cachedCredentials.userId !== userId ||
|
||||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
|
||||
if (shouldRefreshCachedCredentials) {
|
||||
const { saveMatrixCredentials } = await loadCredentialsWriter();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver,
|
||||
@ -402,6 +402,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
accountId,
|
||||
);
|
||||
} else if (hasMatchingCachedToken) {
|
||||
const { touchMatrixCredentials } = await loadCredentialsWriter();
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
}
|
||||
return {
|
||||
@ -418,6 +419,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
const { touchMatrixCredentials } = await loadCredentialsWriter();
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
return {
|
||||
accountId,
|
||||
@ -474,6 +476,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
|
||||
const { saveMatrixCredentials } = await loadCredentialsWriter();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
|
||||
@ -12,7 +12,7 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
rooms: {
|
||||
join: {
|
||||
"!room:example.org": {
|
||||
summary: {},
|
||||
summary: { "m.heroes": [] },
|
||||
state: { events: [] },
|
||||
timeline: {
|
||||
events: [
|
||||
@ -34,6 +34,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
unread_notifications: {},
|
||||
},
|
||||
},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: {
|
||||
events: [
|
||||
@ -88,6 +91,50 @@ describe("FileBackedMatrixSyncStore", () => {
|
||||
},
|
||||
]);
|
||||
expect(savedSync?.roomsData.join?.["!room:example.org"]).toBeTruthy();
|
||||
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats sync state as restart-safe after a clean shutdown persist", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
await firstStore.flush();
|
||||
|
||||
const afterDirtyPersist = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterDirtyPersist.hasSavedSync()).toBe(true);
|
||||
expect(afterDirtyPersist.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const afterCleanShutdown = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterCleanShutdown.hasSavedSync()).toBe(true);
|
||||
expect(afterCleanShutdown.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the clean-shutdown marker once fresh sync data arrives", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const restartedStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(restartedStore.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
|
||||
await restartedStore.setSyncData(createSyncResponse("s456"));
|
||||
await restartedStore.flush();
|
||||
|
||||
const afterNewSync = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterNewSync.hasSavedSync()).toBe(true);
|
||||
expect(afterNewSync.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
await expect(afterNewSync.getSavedSyncToken()).resolves.toBe("s456");
|
||||
});
|
||||
|
||||
it("coalesces background persistence until the debounce window elapses", async () => {
|
||||
|
||||
@ -17,6 +17,7 @@ type PersistedMatrixSyncStore = {
|
||||
version: number;
|
||||
savedSync: ISyncData | null;
|
||||
clientOptions?: IStoredClientOpts;
|
||||
cleanShutdown?: boolean;
|
||||
};
|
||||
|
||||
function createAsyncLock() {
|
||||
@ -52,7 +53,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
nextBatch: value.nextBatch,
|
||||
accountData: value.accountData,
|
||||
roomsData: value.roomsData,
|
||||
} as ISyncData;
|
||||
} as unknown as ISyncData;
|
||||
}
|
||||
|
||||
// Older Matrix state files stored the raw /sync-shaped payload directly.
|
||||
@ -64,7 +65,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
? value.account_data.events
|
||||
: [],
|
||||
roomsData: isRecord(value.rooms) ? value.rooms : {},
|
||||
} as ISyncData;
|
||||
} as unknown as ISyncData;
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -76,6 +77,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
version?: unknown;
|
||||
savedSync?: unknown;
|
||||
clientOptions?: unknown;
|
||||
cleanShutdown?: unknown;
|
||||
};
|
||||
const savedSync = toPersistedSyncData(parsed.savedSync);
|
||||
if (parsed.version === STORE_VERSION) {
|
||||
@ -85,6 +87,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
clientOptions: isRecord(parsed.clientOptions)
|
||||
? (parsed.clientOptions as IStoredClientOpts)
|
||||
: undefined,
|
||||
cleanShutdown: parsed.cleanShutdown === true,
|
||||
};
|
||||
}
|
||||
|
||||
@ -93,6 +96,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync: toPersistedSyncData(parsed),
|
||||
cleanShutdown: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@ -119,6 +123,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
private savedSync: ISyncData | null = null;
|
||||
private savedClientOptions: IStoredClientOpts | undefined;
|
||||
private readonly hadSavedSyncOnLoad: boolean;
|
||||
private readonly hadCleanShutdownOnLoad: boolean;
|
||||
private cleanShutdown = false;
|
||||
private dirty = false;
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private persistPromise: Promise<void> | null = null;
|
||||
@ -128,11 +134,13 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
let restoredCleanShutdown = false;
|
||||
try {
|
||||
const raw = readFileSync(this.storagePath, "utf8");
|
||||
const persisted = readPersistedStore(raw);
|
||||
restoredSavedSync = persisted?.savedSync ?? null;
|
||||
restoredClientOptions = persisted?.clientOptions;
|
||||
restoredCleanShutdown = persisted?.cleanShutdown === true;
|
||||
} catch {
|
||||
// Missing or unreadable sync cache should not block startup.
|
||||
}
|
||||
@ -140,6 +148,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
this.savedSync = restoredSavedSync;
|
||||
this.savedClientOptions = restoredClientOptions;
|
||||
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
|
||||
this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown;
|
||||
this.cleanShutdown = this.hadCleanShutdownOnLoad;
|
||||
|
||||
if (this.savedSync) {
|
||||
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
|
||||
@ -154,6 +164,10 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
return this.hadSavedSyncOnLoad;
|
||||
}
|
||||
|
||||
hasSavedSyncFromCleanShutdown(): boolean {
|
||||
return this.hadCleanShutdownOnLoad;
|
||||
}
|
||||
|
||||
override getSavedSync(): Promise<ISyncData | null> {
|
||||
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
|
||||
}
|
||||
@ -205,9 +219,15 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
await super.deleteAllData();
|
||||
this.savedSync = null;
|
||||
this.savedClientOptions = undefined;
|
||||
this.cleanShutdown = false;
|
||||
await fs.rm(this.storagePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
markCleanShutdown(): void {
|
||||
this.cleanShutdown = true;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
@ -224,6 +244,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
}
|
||||
|
||||
private markDirtyAndSchedulePersist(): void {
|
||||
this.cleanShutdown = false;
|
||||
this.dirty = true;
|
||||
if (this.persistTimer) {
|
||||
return;
|
||||
@ -242,6 +263,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
const payload: PersistedMatrixSyncStore = {
|
||||
version: STORE_VERSION,
|
||||
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
|
||||
cleanShutdown: this.cleanShutdown === true,
|
||||
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
|
||||
};
|
||||
try {
|
||||
|
||||
150
extensions/matrix/src/matrix/credentials-read.ts
Normal file
150
extensions/matrix/src/matrix/credentials-read.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
} from "../storage-paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
|
||||
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
|
||||
}
|
||||
|
||||
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const cfg = getMatrixRuntime().config.loadConfig();
|
||||
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return false;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
|
||||
}
|
||||
|
||||
function resolveLegacyMigrationSourcePath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId?: string | null,
|
||||
): string | null {
|
||||
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
|
||||
return null;
|
||||
}
|
||||
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
|
||||
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
|
||||
}
|
||||
|
||||
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const resolvedStateDir = stateDir ?? resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const resolvedStateDir = resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
return parseMatrixCredentialsFile(credPath);
|
||||
}
|
||||
|
||||
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
|
||||
if (!legacyPath || !fs.existsSync(legacyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseMatrixCredentialsFile(legacyPath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, credPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const paths = [
|
||||
resolveMatrixCredentialsPath(env, accountId),
|
||||
resolveLegacyMigrationSourcePath(env, accountId),
|
||||
];
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string; accessToken?: string },
|
||||
): boolean {
|
||||
if (!config.userId) {
|
||||
if (!config.accessToken) {
|
||||
return false;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
18
extensions/matrix/src/matrix/credentials-write.runtime.ts
Normal file
18
extensions/matrix/src/matrix/credentials-write.runtime.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type {
|
||||
saveMatrixCredentials as saveMatrixCredentialsType,
|
||||
touchMatrixCredentials as touchMatrixCredentialsType,
|
||||
} from "./credentials.js";
|
||||
|
||||
export async function saveMatrixCredentials(
|
||||
...args: Parameters<typeof saveMatrixCredentialsType>
|
||||
): ReturnType<typeof saveMatrixCredentialsType> {
|
||||
const runtime = await import("./credentials.js");
|
||||
return runtime.saveMatrixCredentials(...args);
|
||||
}
|
||||
|
||||
export async function touchMatrixCredentials(
|
||||
...args: Parameters<typeof touchMatrixCredentialsType>
|
||||
): ReturnType<typeof touchMatrixCredentialsType> {
|
||||
const runtime = await import("./credentials.js");
|
||||
return runtime.touchMatrixCredentials(...args);
|
||||
}
|
||||
@ -1,119 +1,15 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { writeJsonFileAtomically } from "../runtime-api.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
} from "../storage-paths.js";
|
||||
import { loadMatrixCredentials, resolveMatrixCredentialsPath } from "./credentials-read.js";
|
||||
import type { MatrixStoredCredentials } from "./credentials-read.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
|
||||
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
|
||||
}
|
||||
|
||||
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const cfg = getMatrixRuntime().config.loadConfig();
|
||||
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return false;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
|
||||
}
|
||||
|
||||
function resolveLegacyMigrationSourcePath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId?: string | null,
|
||||
): string | null {
|
||||
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
|
||||
return null;
|
||||
}
|
||||
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
|
||||
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
|
||||
}
|
||||
|
||||
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const resolvedStateDir = stateDir ?? resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const resolvedStateDir = resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
return parseMatrixCredentialsFile(credPath);
|
||||
}
|
||||
|
||||
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
|
||||
if (!legacyPath || !fs.existsSync(legacyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseMatrixCredentialsFile(legacyPath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, credPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export {
|
||||
clearMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
loadMatrixCredentials,
|
||||
resolveMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath,
|
||||
} from "./credentials-read.js";
|
||||
export type { MatrixStoredCredentials } from "./credentials-read.js";
|
||||
|
||||
export async function saveMatrixCredentials(
|
||||
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
|
||||
@ -147,38 +43,3 @@ export async function touchMatrixCredentials(
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
await writeJsonFileAtomically(credPath, existing);
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const paths = [
|
||||
resolveMatrixCredentialsPath(env, accountId),
|
||||
resolveLegacyMigrationSourcePath(env, accountId),
|
||||
];
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string; accessToken?: string },
|
||||
): boolean {
|
||||
if (!config.userId) {
|
||||
if (!config.accessToken) {
|
||||
return false;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
|
||||
1
extensions/matrix/src/matrix/index.ts
Normal file
1
extensions/matrix/src/matrix/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
@ -62,7 +62,7 @@ function createHarness(params?: {
|
||||
const ensureVerificationDmTracked = vi.fn(
|
||||
params?.ensureVerificationDmTracked ?? (async () => null),
|
||||
);
|
||||
const sendMessage = vi.fn(async () => "$notice");
|
||||
const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice");
|
||||
const invalidateRoom = vi.fn();
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const formatNativeDependencyHint = vi.fn(() => "install hint");
|
||||
|
||||
@ -100,6 +100,7 @@ function createHandlerHarness() {
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now() - 120_000,
|
||||
startupGraceMs: 60_000,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@ -588,11 +588,13 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => false,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
needsRoomAliasesForConfig: false,
|
||||
});
|
||||
|
||||
await handler(
|
||||
|
||||
@ -115,6 +115,7 @@ describe("createMatrixRoomMessageHandler thread root media", () => {
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now() - 120_000,
|
||||
startupGraceMs: 60_000,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@ -7,7 +7,6 @@ const hoisted = vi.hoisted(() => {
|
||||
hasPersistedSyncState: vi.fn(() => false),
|
||||
};
|
||||
const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
|
||||
let startClientError: Error | null = null;
|
||||
const resolveTextChunkLimit = vi.fn<
|
||||
(cfg: unknown, channel: unknown, accountId?: unknown) => number
|
||||
>(() => 4000);
|
||||
@ -18,17 +17,17 @@ const hoisted = vi.hoisted(() => {
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const stopThreadBindingManager = vi.fn();
|
||||
const stopSharedClientInstance = vi.fn();
|
||||
const releaseSharedClientInstance = vi.fn(async () => true);
|
||||
const setActiveMatrixClient = vi.fn();
|
||||
return {
|
||||
callOrder,
|
||||
client,
|
||||
createMatrixRoomMessageHandler,
|
||||
logger,
|
||||
releaseSharedClientInstance,
|
||||
resolveTextChunkLimit,
|
||||
setActiveMatrixClient,
|
||||
startClientError,
|
||||
stopSharedClientInstance,
|
||||
startClientError: null as Error | null,
|
||||
stopThreadBindingManager,
|
||||
};
|
||||
});
|
||||
@ -128,7 +127,10 @@ vi.mock("../client.js", () => ({
|
||||
hoisted.callOrder.push("start-client");
|
||||
return hoisted.client;
|
||||
}),
|
||||
stopSharedClientInstance: hoisted.stopSharedClientInstance,
|
||||
}));
|
||||
|
||||
vi.mock("../client/shared.js", () => ({
|
||||
releaseSharedClientInstance: hoisted.releaseSharedClientInstance,
|
||||
}));
|
||||
|
||||
vi.mock("../config-update.js", () => ({
|
||||
@ -207,8 +209,8 @@ describe("monitorMatrixProvider", () => {
|
||||
hoisted.callOrder.length = 0;
|
||||
hoisted.startClientError = null;
|
||||
hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
|
||||
hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true);
|
||||
hoisted.setActiveMatrixClient.mockReset();
|
||||
hoisted.stopSharedClientInstance.mockReset();
|
||||
hoisted.stopThreadBindingManager.mockReset();
|
||||
hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false);
|
||||
hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn());
|
||||
@ -252,12 +254,13 @@ describe("monitorMatrixProvider", () => {
|
||||
await expect(monitorMatrixProvider()).rejects.toThrow("start failed");
|
||||
|
||||
expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.stopSharedClientInstance).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
|
||||
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default");
|
||||
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default");
|
||||
});
|
||||
|
||||
it("disables cold-start backlog dropping when sync state already exists", async () => {
|
||||
it("disables cold-start backlog dropping only when sync state is cleanly persisted", async () => {
|
||||
hoisted.client.hasPersistedSyncState.mockReturnValue(true);
|
||||
const { monitorMatrixProvider } = await import("./index.js");
|
||||
const abortController = new AbortController();
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClientInstance,
|
||||
} from "../client.js";
|
||||
import { releaseSharedClientInstance } from "../client/shared.js";
|
||||
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { resolveMatrixMonitorConfig } from "./config.js";
|
||||
@ -131,7 +131,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
setActiveMatrixClient(client, auth.accountId);
|
||||
let cleanedUp = false;
|
||||
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
|
||||
const cleanup = () => {
|
||||
const cleanup = async () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
@ -139,7 +139,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
try {
|
||||
threadBindingManager?.stop();
|
||||
} finally {
|
||||
stopSharedClientInstance(client);
|
||||
await releaseSharedClientInstance(client, "persist");
|
||||
setActiveMatrixClient(null, auth.accountId);
|
||||
}
|
||||
};
|
||||
@ -273,19 +273,32 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
cleanup();
|
||||
resolve();
|
||||
const stopAndResolve = async () => {
|
||||
try {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
await cleanup();
|
||||
} catch (err) {
|
||||
logger.warn("matrix: failed during monitor shutdown cleanup", {
|
||||
error: String(err),
|
||||
});
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
if (opts.abortSignal?.aborted) {
|
||||
onAbort();
|
||||
void stopAndResolve();
|
||||
return;
|
||||
}
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
opts.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void stopAndResolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
await cleanup();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
createTestRegistry,
|
||||
type OpenClawConfig,
|
||||
resolveAgentRoute,
|
||||
registerSessionBindingAdapter,
|
||||
} from "../../../../../src/infra/outbound/session-binding-service.js";
|
||||
import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js";
|
||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||
import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js";
|
||||
sessionBindingTesting,
|
||||
setActivePluginRegistry,
|
||||
} from "../../../../../test/helpers/extensions/matrix-route-test.js";
|
||||
import { matrixPlugin } from "../../channel.js";
|
||||
import { resolveMatrixInboundRoute } from "./route.js";
|
||||
|
||||
|
||||
@ -222,7 +222,10 @@ describe("MatrixClient request hardening", () => {
|
||||
|
||||
it("prefers authenticated client media downloads", async () => {
|
||||
const payload = Buffer.from([1, 2, 3, 4]);
|
||||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(payload, { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
|
||||
@ -4,6 +4,7 @@ import { EventEmitter } from "node:events";
|
||||
import {
|
||||
ClientEvent,
|
||||
MatrixEventEvent,
|
||||
Preset,
|
||||
createClient as createMatrixJsClient,
|
||||
type MatrixClient as MatrixJsClient,
|
||||
type MatrixEvent,
|
||||
@ -349,7 +350,9 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
hasPersistedSyncState(): boolean {
|
||||
return this.syncStore?.hasSavedSync() === true;
|
||||
// Only trust restart replay when the previous process completed a final
|
||||
// sync-store persist. A stale cursor can make Matrix re-surface old events.
|
||||
return this.syncStore?.hasSavedSyncFromCleanShutdown() === true;
|
||||
}
|
||||
|
||||
private async ensureStartedForCryptoControlPlane(): Promise<void> {
|
||||
@ -366,6 +369,7 @@ export class MatrixClient {
|
||||
}
|
||||
this.decryptBridge.stop();
|
||||
// Final persist on shutdown
|
||||
this.syncStore?.markCleanShutdown();
|
||||
this.stopPersistPromise = Promise.all([
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
@ -547,7 +551,7 @@ export class MatrixClient {
|
||||
const result = await this.client.createRoom({
|
||||
invite: [remoteUserId],
|
||||
is_direct: true,
|
||||
preset: "trusted_private_chat",
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
initial_state: initialState,
|
||||
});
|
||||
return result.room_id;
|
||||
|
||||
@ -173,6 +173,7 @@ function resolveBindingsPath(params: {
|
||||
auth: MatrixAuth;
|
||||
accountId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): string {
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.auth.homeserver,
|
||||
@ -181,6 +182,7 @@ function resolveBindingsPath(params: {
|
||||
accountId: params.accountId,
|
||||
deviceId: params.auth.deviceId,
|
||||
env: params.env,
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
return path.join(storagePaths.rootDir, "thread-bindings.json");
|
||||
}
|
||||
@ -341,6 +343,7 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
auth: MatrixAuth;
|
||||
client: MatrixClient;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
idleTimeoutMs: number;
|
||||
maxAgeMs: number;
|
||||
enableSweeper?: boolean;
|
||||
@ -360,6 +363,7 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
auth: params.auth,
|
||||
accountId: params.accountId,
|
||||
env: params.env,
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
const loaded = await loadBindingsFromDisk(filePath, params.accountId);
|
||||
for (const record of loaded) {
|
||||
@ -621,14 +625,6 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
});
|
||||
return record ? toSessionBindingRecord(record, defaults) : null;
|
||||
},
|
||||
setIdleTimeoutBySession: ({ targetSessionKey, idleTimeoutMs }) =>
|
||||
manager
|
||||
.setIdleTimeoutBySessionKey({ targetSessionKey, idleTimeoutMs })
|
||||
.map((record) => toSessionBindingRecord(record, defaults)),
|
||||
setMaxAgeBySession: ({ targetSessionKey, maxAgeMs }) =>
|
||||
manager
|
||||
.setMaxAgeBySessionKey({ targetSessionKey, maxAgeMs })
|
||||
.map((record) => toSessionBindingRecord(record, defaults)),
|
||||
touch: (bindingId, at) => {
|
||||
manager.touchBinding(bindingId, at);
|
||||
},
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizardAdapter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import {
|
||||
@ -36,6 +33,54 @@ import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
|
||||
type MatrixOnboardingStatus = {
|
||||
channel: typeof channel;
|
||||
configured: boolean;
|
||||
statusLines: string[];
|
||||
selectionHint?: string;
|
||||
quickstartScore?: number;
|
||||
};
|
||||
|
||||
type MatrixAccountOverrides = Partial<Record<typeof channel, string>>;
|
||||
|
||||
type MatrixOnboardingConfigureContext = {
|
||||
cfg: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
options?: unknown;
|
||||
forceAllowFrom: boolean;
|
||||
accountOverrides: MatrixAccountOverrides;
|
||||
shouldPromptAccountIds: boolean;
|
||||
};
|
||||
|
||||
type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & {
|
||||
configured: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type MatrixOnboardingAdapter = {
|
||||
channel: typeof channel;
|
||||
getStatus: (ctx: {
|
||||
cfg: CoreConfig;
|
||||
options?: unknown;
|
||||
accountOverrides: MatrixAccountOverrides;
|
||||
}) => Promise<MatrixOnboardingStatus>;
|
||||
configure: (
|
||||
ctx: MatrixOnboardingConfigureContext,
|
||||
) => Promise<{ cfg: CoreConfig; accountId?: string }>;
|
||||
configureInteractive?: (
|
||||
ctx: MatrixOnboardingInteractiveContext,
|
||||
) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">;
|
||||
afterConfigWritten?: (ctx: {
|
||||
previousCfg: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}) => Promise<void> | void;
|
||||
dmPolicy?: ChannelSetupDmPolicy;
|
||||
disable?: (cfg: CoreConfig) => CoreConfig;
|
||||
};
|
||||
|
||||
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
|
||||
return normalizeAccountId(
|
||||
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
|
||||
@ -473,7 +518,7 @@ async function runMatrixConfigure(params: {
|
||||
return { cfg: next, accountId };
|
||||
}
|
||||
|
||||
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
export const matrixOnboardingAdapter: MatrixOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg, accountOverrides }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
|
||||
@ -119,6 +119,25 @@ describe("handleMatrixAction pollVote", () => {
|
||||
).rejects.toThrow("pollId required");
|
||||
});
|
||||
|
||||
it("accepts messageId as a pollId alias for poll votes", async () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
await handleMatrixAction(
|
||||
{
|
||||
action: "pollVote",
|
||||
roomId: "!room:example",
|
||||
messageId: "$poll",
|
||||
pollOptionIndex: 1,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", {
|
||||
cfg,
|
||||
optionIds: [],
|
||||
optionIndexes: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes account-scoped opts to add reactions", async () => {
|
||||
const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig;
|
||||
await handleMatrixAction(
|
||||
|
||||
@ -97,6 +97,27 @@ function readRawParam(params: Record<string, unknown>, key: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStringAliasParam(
|
||||
params: Record<string, unknown>,
|
||||
keys: string[],
|
||||
options: { required?: boolean } = {},
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const raw = readRawParam(params, key);
|
||||
if (typeof raw !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
if (options.required) {
|
||||
throw new Error(`${keys[0]} required`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumericArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
@ -169,7 +190,10 @@ export async function handleMatrixAction(
|
||||
|
||||
if (pollActions.has(action)) {
|
||||
const roomId = readRoomId(params);
|
||||
const pollId = readStringParam(params, "pollId", { required: true });
|
||||
const pollId = readStringAliasParam(params, ["pollId", "messageId"], { required: true });
|
||||
if (!pollId) {
|
||||
throw new Error("pollId required");
|
||||
}
|
||||
const optionId = readStringParam(params, "pollOptionId");
|
||||
const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true });
|
||||
const optionIds = [
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "../../src/plugin-sdk/nextcloud-talk.js";
|
||||
export * from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { peekSystemEvents } from "../../../src/infra/system-events.js";
|
||||
import { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
import type { SignalDaemonExitEvent } from "./daemon.js";
|
||||
@ -16,7 +15,11 @@ import {
|
||||
installSignalToolResultTestHooks();
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for Signal internals.
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
vi.resetModules();
|
||||
const [{ peekSystemEvents }, { monitorSignalProvider }] = await Promise.all([
|
||||
import("openclaw/plugin-sdk/infra-runtime"),
|
||||
import("./monitor.js"),
|
||||
]);
|
||||
|
||||
const {
|
||||
replyMock,
|
||||
@ -76,6 +79,7 @@ function createAutoAbortController() {
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider({
|
||||
config: config as OpenClawConfig,
|
||||
waitForTransportReady: waitForTransportReadyMock as any,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
@ -171,7 +171,7 @@ export function installSignalToolResultTestHooks() {
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
streamMock.mockReset();
|
||||
signalCheckMock.mockReset().mockResolvedValue({});
|
||||
signalCheckMock.mockReset().mockResolvedValue({ ok: true });
|
||||
signalRpcRequestMock.mockReset().mockResolvedValue({});
|
||||
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
||||
@ -13,13 +13,13 @@ import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
|
||||
@ -56,6 +56,7 @@ export type MonitorSignalOpts = {
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
reconnectPolicy?: Partial<BackoffPolicy>;
|
||||
waitForTransportReady?: typeof waitForTransportReady;
|
||||
};
|
||||
|
||||
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
@ -217,8 +218,10 @@ async function waitForSignalDaemonReady(params: {
|
||||
logAfterMs: number;
|
||||
logIntervalMs?: number;
|
||||
runtime: RuntimeEnv;
|
||||
waitForTransportReadyFn?: typeof waitForTransportReady;
|
||||
}): Promise<void> {
|
||||
await waitForTransportReady({
|
||||
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
|
||||
await waitForTransportReadyFn({
|
||||
label: "signal daemon",
|
||||
timeoutMs: params.timeoutMs,
|
||||
logAfterMs: params.logAfterMs,
|
||||
@ -374,6 +377,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady;
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const startupTimeoutMs = Math.min(
|
||||
@ -416,6 +420,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
waitForTransportReadyFn,
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
|
||||
@ -13,10 +13,10 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import type { TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
@ -238,6 +238,7 @@ async function deliverMediaReply(params: {
|
||||
tableMode?: MarkdownTableMode;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
chunkText: ChunkTextFn;
|
||||
mediaLoader: typeof loadWebMedia;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
@ -252,7 +253,7 @@ async function deliverMediaReply(params: {
|
||||
let pendingFollowUpText: string | undefined;
|
||||
for (const mediaUrl of params.mediaList) {
|
||||
const isFirstMedia = first;
|
||||
const media = await loadWebMedia(
|
||||
const media = await params.mediaLoader(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
|
||||
);
|
||||
@ -569,12 +570,15 @@ export async function deliverReplies(params: {
|
||||
silent?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
/** Override media loader (tests). */
|
||||
mediaLoader?: typeof loadWebMedia;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress: DeliveryProgress = {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
deliveredCount: 0,
|
||||
};
|
||||
const mediaLoader = params.mediaLoader ?? loadWebMedia;
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
|
||||
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
|
||||
@ -663,6 +667,7 @@ export async function deliverReplies(params: {
|
||||
tableMode: params.tableMode,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
chunkText,
|
||||
mediaLoader,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { deliverReplies } from "./delivery.js";
|
||||
|
||||
const loadWebMedia = vi.fn();
|
||||
const { loadWebMedia } = vi.hoisted(() => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const messageHookRunner = vi.hoisted(() => ({
|
||||
hasHooks: vi.fn<(name: string) => boolean>(() => false),
|
||||
@ -21,12 +22,15 @@ type DeliverWithParams = Omit<
|
||||
DeliverRepliesParams,
|
||||
"chatId" | "token" | "replyToMode" | "textLimit"
|
||||
> &
|
||||
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
|
||||
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit" | "mediaLoader">>;
|
||||
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/web-media.js", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => messageHookRunner,
|
||||
@ -42,6 +46,9 @@ vi.mock("../../../../src/hooks/internal-hooks.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { deliverReplies } = await import("./delivery.js");
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
InputFile: class {
|
||||
constructor(
|
||||
@ -70,6 +77,7 @@ async function deliverWith(params: DeliverWithParams) {
|
||||
await deliverReplies({
|
||||
...baseDeliveryParams,
|
||||
...params,
|
||||
mediaLoader: params.mediaLoader ?? loadWebMedia,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
36
extensions/whatsapp/src/active-listener.test.ts
Normal file
36
extensions/whatsapp/src/active-listener.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ActiveListenerModule = typeof import("./active-listener.js");
|
||||
|
||||
const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href;
|
||||
|
||||
async function importActiveListenerModule(cacheBust: string): Promise<ActiveListenerModule> {
|
||||
return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const mod = await importActiveListenerModule(`cleanup-${Date.now()}`);
|
||||
mod.setActiveWebListener(null);
|
||||
mod.setActiveWebListener("work", null);
|
||||
});
|
||||
|
||||
describe("active WhatsApp listener singleton", () => {
|
||||
it("shares listeners across duplicate module instances", async () => {
|
||||
const first = await importActiveListenerModule(`first-${Date.now()}`);
|
||||
const second = await importActiveListenerModule(`second-${Date.now()}`);
|
||||
const listener = {
|
||||
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
|
||||
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
|
||||
sendReaction: vi.fn(async () => {}),
|
||||
sendComposingTo: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
first.setActiveWebListener("work", listener);
|
||||
|
||||
expect(second.getActiveWebListener("work")).toBe(listener);
|
||||
expect(second.requireActiveWebListener("work")).toEqual({
|
||||
accountId: "work",
|
||||
listener,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -28,27 +28,22 @@ export type ActiveWebListener = {
|
||||
close?: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Use a process-level singleton to survive bundler code-splitting.
|
||||
// Rolldown duplicates this module across multiple output chunks, each with its
|
||||
// own module-scoped `listeners` Map. The WhatsApp provider writes to one chunk's
|
||||
// Map via setActiveWebListener(), but the outbound send path reads from a
|
||||
// different chunk's Map via requireActiveWebListener() — so the listener is
|
||||
// never found. Pinning the Map to globalThis ensures all chunks share one
|
||||
// instance. See: https://github.com/openclaw/openclaw/issues/14406
|
||||
const GLOBAL_KEY = "__openclaw_wa_listeners" as const;
|
||||
const GLOBAL_CURRENT_KEY = "__openclaw_wa_current_listener" as const;
|
||||
// Use process-global symbol keys to survive bundler code-splitting and loader
|
||||
// cache splits without depending on fragile string property names.
|
||||
const GLOBAL_LISTENERS_KEY = Symbol.for("openclaw.whatsapp.activeListeners");
|
||||
const GLOBAL_CURRENT_KEY = Symbol.for("openclaw.whatsapp.currentListener");
|
||||
|
||||
type GlobalWithListeners = typeof globalThis & {
|
||||
[GLOBAL_KEY]?: Map<string, ActiveWebListener>;
|
||||
[GLOBAL_LISTENERS_KEY]?: Map<string, ActiveWebListener>;
|
||||
[GLOBAL_CURRENT_KEY]?: ActiveWebListener | null;
|
||||
};
|
||||
|
||||
const _global = globalThis as GlobalWithListeners;
|
||||
|
||||
_global[GLOBAL_KEY] ??= new Map<string, ActiveWebListener>();
|
||||
_global[GLOBAL_LISTENERS_KEY] ??= new Map<string, ActiveWebListener>();
|
||||
_global[GLOBAL_CURRENT_KEY] ??= null;
|
||||
|
||||
const listeners = _global[GLOBAL_KEY];
|
||||
const listeners = _global[GLOBAL_LISTENERS_KEY];
|
||||
|
||||
function getCurrentListener(): ActiveWebListener | null {
|
||||
return _global[GLOBAL_CURRENT_KEY] ?? null;
|
||||
|
||||
340
infra/azure/templates/azuredeploy.json
Normal file
340
infra/azure/templates/azuredeploy.json
Normal file
@ -0,0 +1,340 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"defaultValue": "westus2",
|
||||
"metadata": {
|
||||
"description": "Azure region for all resources. Any valid Azure region is allowed (no allowedValues restriction)."
|
||||
}
|
||||
},
|
||||
"vmName": {
|
||||
"type": "string",
|
||||
"defaultValue": "vm-openclaw",
|
||||
"metadata": {
|
||||
"description": "OpenClaw VM name."
|
||||
}
|
||||
},
|
||||
"vmSize": {
|
||||
"type": "string",
|
||||
"defaultValue": "Standard_B2as_v2",
|
||||
"metadata": {
|
||||
"description": "Azure VM size for OpenClaw host."
|
||||
}
|
||||
},
|
||||
"adminUsername": {
|
||||
"type": "string",
|
||||
"defaultValue": "openclaw",
|
||||
"minLength": 1,
|
||||
"maxLength": 32,
|
||||
"metadata": {
|
||||
"description": "Linux admin username."
|
||||
}
|
||||
},
|
||||
"sshPublicKey": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"description": "SSH public key content (for example ssh-ed25519 ...)."
|
||||
}
|
||||
},
|
||||
"vnetName": {
|
||||
"type": "string",
|
||||
"defaultValue": "vnet-openclaw",
|
||||
"metadata": {
|
||||
"description": "Virtual network name."
|
||||
}
|
||||
},
|
||||
"vnetAddressPrefix": {
|
||||
"type": "string",
|
||||
"defaultValue": "10.40.0.0/16",
|
||||
"metadata": {
|
||||
"description": "Address space for the virtual network."
|
||||
}
|
||||
},
|
||||
"vmSubnetName": {
|
||||
"type": "string",
|
||||
"defaultValue": "snet-openclaw-vm",
|
||||
"metadata": {
|
||||
"description": "Subnet name for OpenClaw VM."
|
||||
}
|
||||
},
|
||||
"vmSubnetPrefix": {
|
||||
"type": "string",
|
||||
"defaultValue": "10.40.2.0/24",
|
||||
"metadata": {
|
||||
"description": "Address prefix for VM subnet."
|
||||
}
|
||||
},
|
||||
"bastionSubnetPrefix": {
|
||||
"type": "string",
|
||||
"defaultValue": "10.40.1.0/26",
|
||||
"metadata": {
|
||||
"description": "Address prefix for AzureBastionSubnet (must be /26 or larger)."
|
||||
}
|
||||
},
|
||||
"nsgName": {
|
||||
"type": "string",
|
||||
"defaultValue": "nsg-openclaw-vm",
|
||||
"metadata": {
|
||||
"description": "Network security group for VM subnet."
|
||||
}
|
||||
},
|
||||
"nicName": {
|
||||
"type": "string",
|
||||
"defaultValue": "nic-openclaw-vm",
|
||||
"metadata": {
|
||||
"description": "NIC for OpenClaw VM."
|
||||
}
|
||||
},
|
||||
"bastionName": {
|
||||
"type": "string",
|
||||
"defaultValue": "bas-openclaw",
|
||||
"metadata": {
|
||||
"description": "Azure Bastion host name."
|
||||
}
|
||||
},
|
||||
"bastionPublicIpName": {
|
||||
"type": "string",
|
||||
"defaultValue": "pip-openclaw-bastion",
|
||||
"metadata": {
|
||||
"description": "Public IP used by Bastion."
|
||||
}
|
||||
},
|
||||
"osDiskSizeGb": {
|
||||
"type": "int",
|
||||
"defaultValue": 64,
|
||||
"minValue": 30,
|
||||
"maxValue": 1024,
|
||||
"metadata": {
|
||||
"description": "OS disk size in GiB."
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"bastionSubnetName": "AzureBastionSubnet"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "Microsoft.Network/networkSecurityGroups",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('nsgName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"properties": {
|
||||
"securityRules": [
|
||||
{
|
||||
"name": "AllowSshFromAzureBastionSubnet",
|
||||
"properties": {
|
||||
"priority": 100,
|
||||
"access": "Allow",
|
||||
"direction": "Inbound",
|
||||
"protocol": "Tcp",
|
||||
"sourcePortRange": "*",
|
||||
"destinationPortRange": "22",
|
||||
"sourceAddressPrefix": "[parameters('bastionSubnetPrefix')]",
|
||||
"destinationAddressPrefix": "*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DenyInternetSsh",
|
||||
"properties": {
|
||||
"priority": 110,
|
||||
"access": "Deny",
|
||||
"direction": "Inbound",
|
||||
"protocol": "Tcp",
|
||||
"sourcePortRange": "*",
|
||||
"destinationPortRange": "22",
|
||||
"sourceAddressPrefix": "Internet",
|
||||
"destinationAddressPrefix": "*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DenyVnetSsh",
|
||||
"properties": {
|
||||
"priority": 120,
|
||||
"access": "Deny",
|
||||
"direction": "Inbound",
|
||||
"protocol": "Tcp",
|
||||
"sourcePortRange": "*",
|
||||
"destinationPortRange": "22",
|
||||
"sourceAddressPrefix": "VirtualNetwork",
|
||||
"destinationAddressPrefix": "*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/virtualNetworks",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('vnetName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"properties": {
|
||||
"addressSpace": {
|
||||
"addressPrefixes": ["[parameters('vnetAddressPrefix')]"]
|
||||
},
|
||||
"subnets": [
|
||||
{
|
||||
"name": "[variables('bastionSubnetName')]",
|
||||
"properties": {
|
||||
"addressPrefix": "[parameters('bastionSubnetPrefix')]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "[parameters('vmSubnetName')]",
|
||||
"properties": {
|
||||
"addressPrefix": "[parameters('vmSubnetPrefix')]",
|
||||
"networkSecurityGroup": {
|
||||
"id": "[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependsOn": [
|
||||
"[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/publicIPAddresses",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('bastionPublicIpName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"sku": {
|
||||
"name": "Standard"
|
||||
},
|
||||
"properties": {
|
||||
"publicIPAllocationMethod": "Static"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/bastionHosts",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('bastionName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"sku": {
|
||||
"name": "Standard"
|
||||
},
|
||||
"dependsOn": [
|
||||
"[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]",
|
||||
"[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]"
|
||||
],
|
||||
"properties": {
|
||||
"enableTunneling": true,
|
||||
"ipConfigurations": [
|
||||
{
|
||||
"name": "bastionIpConfig",
|
||||
"properties": {
|
||||
"subnet": {
|
||||
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), variables('bastionSubnetName'))]"
|
||||
},
|
||||
"publicIPAddress": {
|
||||
"id": "[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/networkInterfaces",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('nicName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"dependsOn": ["[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]"],
|
||||
"properties": {
|
||||
"ipConfigurations": [
|
||||
{
|
||||
"name": "ipconfig1",
|
||||
"properties": {
|
||||
"privateIPAllocationMethod": "Dynamic",
|
||||
"subnet": {
|
||||
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('vmSubnetName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Compute/virtualMachines",
|
||||
"apiVersion": "2023-09-01",
|
||||
"name": "[parameters('vmName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"dependsOn": ["[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"],
|
||||
"properties": {
|
||||
"hardwareProfile": {
|
||||
"vmSize": "[parameters('vmSize')]"
|
||||
},
|
||||
"osProfile": {
|
||||
"computerName": "[parameters('vmName')]",
|
||||
"adminUsername": "[parameters('adminUsername')]",
|
||||
"linuxConfiguration": {
|
||||
"disablePasswordAuthentication": true,
|
||||
"ssh": {
|
||||
"publicKeys": [
|
||||
{
|
||||
"path": "[concat('/home/', parameters('adminUsername'), '/.ssh/authorized_keys')]",
|
||||
"keyData": "[parameters('sshPublicKey')]"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storageProfile": {
|
||||
"imageReference": {
|
||||
"publisher": "Canonical",
|
||||
"offer": "ubuntu-24_04-lts",
|
||||
"sku": "server",
|
||||
"version": "latest"
|
||||
},
|
||||
"osDisk": {
|
||||
"createOption": "FromImage",
|
||||
"diskSizeGB": "[parameters('osDiskSizeGb')]",
|
||||
"managedDisk": {
|
||||
"storageAccountType": "StandardSSD_LRS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkProfile": {
|
||||
"networkInterfaces": [
|
||||
{
|
||||
"id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"
|
||||
}
|
||||
]
|
||||
},
|
||||
"diagnosticsProfile": {
|
||||
"bootDiagnostics": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": {
|
||||
"vmName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('vmName')]"
|
||||
},
|
||||
"vmPrivateIp": {
|
||||
"type": "string",
|
||||
"value": "[reference(resourceId('Microsoft.Network/networkInterfaces', parameters('nicName')), '2023-11-01').ipConfigurations[0].properties.privateIPAddress]"
|
||||
},
|
||||
"vnetName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('vnetName')]"
|
||||
},
|
||||
"vmSubnetName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('vmSubnetName')]"
|
||||
},
|
||||
"bastionName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('bastionName')]"
|
||||
},
|
||||
"bastionResourceId": {
|
||||
"type": "string",
|
||||
"value": "[resourceId('Microsoft.Network/bastionHosts', parameters('bastionName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
infra/azure/templates/azuredeploy.parameters.json
Normal file
48
infra/azure/templates/azuredeploy.parameters.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"location": {
|
||||
"value": "westus2"
|
||||
},
|
||||
"vmName": {
|
||||
"value": "vm-openclaw"
|
||||
},
|
||||
"vmSize": {
|
||||
"value": "Standard_B2as_v2"
|
||||
},
|
||||
"adminUsername": {
|
||||
"value": "openclaw"
|
||||
},
|
||||
"vnetName": {
|
||||
"value": "vnet-openclaw"
|
||||
},
|
||||
"vnetAddressPrefix": {
|
||||
"value": "10.40.0.0/16"
|
||||
},
|
||||
"vmSubnetName": {
|
||||
"value": "snet-openclaw-vm"
|
||||
},
|
||||
"vmSubnetPrefix": {
|
||||
"value": "10.40.2.0/24"
|
||||
},
|
||||
"bastionSubnetPrefix": {
|
||||
"value": "10.40.1.0/26"
|
||||
},
|
||||
"nsgName": {
|
||||
"value": "nsg-openclaw-vm"
|
||||
},
|
||||
"nicName": {
|
||||
"value": "nic-openclaw-vm"
|
||||
},
|
||||
"bastionName": {
|
||||
"value": "bas-openclaw"
|
||||
},
|
||||
"bastionPublicIpName": {
|
||||
"value": "pip-openclaw-bastion"
|
||||
},
|
||||
"osDiskSizeGb": {
|
||||
"value": 64
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import * as heartbeatWake from "../infra/heartbeat-wake.js";
|
||||
import {
|
||||
__testing as sessionBindingServiceTesting,
|
||||
registerSessionBindingAdapter,
|
||||
type SessionBindingPlacement,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js";
|
||||
@ -104,7 +105,7 @@ function createSessionBindingCapabilities() {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"] as const,
|
||||
placements: ["current", "child"] satisfies SessionBindingPlacement[],
|
||||
};
|
||||
}
|
||||
|
||||
@ -179,8 +180,8 @@ describe("spawnAcpDirect", () => {
|
||||
metaCleared: false,
|
||||
});
|
||||
getAcpSessionManagerSpy.mockReset().mockReturnValue({
|
||||
initializeSession: async (params) => await hoisted.initializeSessionMock(params),
|
||||
closeSession: async (params) => await hoisted.closeSessionMock(params),
|
||||
initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params),
|
||||
closeSession: async (params: unknown) => await hoisted.closeSessionMock(params),
|
||||
} as unknown as ReturnType<typeof acpSessionManager.getAcpSessionManager>);
|
||||
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
|
||||
const args = argsUnknown as {
|
||||
@ -1039,7 +1040,7 @@ describe("spawnAcpDirect", () => {
|
||||
...hoisted.state.cfg.channels,
|
||||
telegram: {
|
||||
threadBindings: {
|
||||
spawnAcpSessions: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -11,20 +11,12 @@ function createFlushOnParagraphChunker(params: { minChars: number; maxChars: num
|
||||
});
|
||||
}
|
||||
|
||||
function drainChunks(chunker: EmbeddedBlockChunker) {
|
||||
function drainChunks(chunker: EmbeddedBlockChunker, force = false) {
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
chunker.drain({ force, emit: (chunk) => chunks.push(chunk) });
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function expectFlushAtFirstParagraphBreak(text: string) {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
chunker.append(text);
|
||||
const chunks = drainChunks(chunker);
|
||||
expect(chunks).toEqual(["First paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Second paragraph.");
|
||||
}
|
||||
|
||||
describe("EmbeddedBlockChunker", () => {
|
||||
it("breaks at paragraph boundary right after fence close", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
@ -54,12 +46,25 @@ describe("EmbeddedBlockChunker", () => {
|
||||
expect(chunker.bufferedText).toMatch(/^After/);
|
||||
});
|
||||
|
||||
it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => {
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph.");
|
||||
it("waits until minChars before flushing paragraph boundaries when flushOnParagraph is set", () => {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 30, maxChars: 200 });
|
||||
|
||||
chunker.append("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.");
|
||||
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks).toEqual(["First paragraph.\n\nSecond paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Third paragraph.");
|
||||
});
|
||||
|
||||
it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
|
||||
it("still force flushes buffered paragraphs below minChars at the end", () => {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
|
||||
chunker.append("First paragraph.\n \nSecond paragraph.");
|
||||
|
||||
expect(drainChunks(chunker)).toEqual([]);
|
||||
expect(drainChunks(chunker, true)).toEqual(["First paragraph.\n \nSecond paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
|
||||
@ -97,7 +102,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
it("ignores paragraph breaks inside fences when flushOnParagraph is set", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
minChars: 100,
|
||||
minChars: 10,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph",
|
||||
flushOnParagraph: true,
|
||||
|
||||
@ -5,7 +5,7 @@ export type BlockReplyChunking = {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
/** When true, flush eagerly on \n\n paragraph boundaries regardless of minChars. */
|
||||
/** When true, prefer \n\n paragraph boundaries once minChars has been satisfied. */
|
||||
flushOnParagraph?: boolean;
|
||||
};
|
||||
|
||||
@ -129,7 +129,7 @@ export class EmbeddedBlockChunker {
|
||||
const minChars = Math.max(1, Math.floor(this.#chunking.minChars));
|
||||
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
|
||||
|
||||
if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
if (this.#buffer.length < minChars && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -150,12 +150,12 @@ export class EmbeddedBlockChunker {
|
||||
const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : "";
|
||||
const remainingLength = reopenPrefix.length + (source.length - start);
|
||||
|
||||
if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) {
|
||||
if (!force && remainingLength < minChars) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.#chunking.flushOnParagraph && !force) {
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start);
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start, minChars);
|
||||
const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length);
|
||||
if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) {
|
||||
const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`;
|
||||
@ -175,12 +175,7 @@ export class EmbeddedBlockChunker {
|
||||
const breakResult =
|
||||
force && remainingLength <= maxChars
|
||||
? this.#pickSoftBreakIndex(view, fenceSpans, 1, start)
|
||||
: this.#pickBreakIndex(
|
||||
view,
|
||||
fenceSpans,
|
||||
force || this.#chunking.flushOnParagraph ? 1 : undefined,
|
||||
start,
|
||||
);
|
||||
: this.#pickBreakIndex(view, fenceSpans, force ? 1 : undefined, start);
|
||||
if (breakResult.index <= 0) {
|
||||
if (force) {
|
||||
emit(`${reopenPrefix}${source.slice(start)}`);
|
||||
@ -205,7 +200,7 @@ export class EmbeddedBlockChunker {
|
||||
|
||||
const nextLength =
|
||||
(reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start);
|
||||
if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
if (nextLength < minChars && !force) {
|
||||
break;
|
||||
}
|
||||
if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
@ -401,6 +396,7 @@ function findNextParagraphBreak(
|
||||
buffer: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
startIndex = 0,
|
||||
minCharsFromStart = 1,
|
||||
): ParagraphBreak | null {
|
||||
if (startIndex < 0) {
|
||||
return null;
|
||||
@ -413,6 +409,9 @@ function findNextParagraphBreak(
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
if (index - startIndex < minCharsFromStart) {
|
||||
continue;
|
||||
}
|
||||
if (!isSafeFenceBreak(fenceSpans, index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -68,8 +68,8 @@ const readLatestAssistantReplyMock = vi.fn(
|
||||
const embeddedRunMock = {
|
||||
isEmbeddedPiRunActive: vi.fn(() => false),
|
||||
isEmbeddedPiRunStreaming: vi.fn(() => false),
|
||||
queueEmbeddedPiMessage: vi.fn(() => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
|
||||
queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true),
|
||||
};
|
||||
const { subagentRegistryMock } = vi.hoisted(() => ({
|
||||
subagentRegistryMock: {
|
||||
@ -131,11 +131,17 @@ function setConfigOverride(next: OpenClawConfig): void {
|
||||
setRuntimeConfigSnapshot(configOverride);
|
||||
}
|
||||
|
||||
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
|
||||
return new Proxy(sessionStore, {
|
||||
function loadSessionStoreFixture(): ReturnType<typeof configSessions.loadSessionStore> {
|
||||
return new Proxy(sessionStore as ReturnType<typeof configSessions.loadSessionStore>, {
|
||||
get(target, key: string | symbol) {
|
||||
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
|
||||
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
|
||||
return {
|
||||
sessionId: key,
|
||||
updatedAt: Date.now(),
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
};
|
||||
}
|
||||
return target[key as keyof typeof target];
|
||||
},
|
||||
@ -207,7 +213,11 @@ describe("subagent announce formatting", () => {
|
||||
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
|
||||
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
|
||||
resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main");
|
||||
getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock);
|
||||
getGlobalHookRunnerSpy
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
() => hookRunnerMock as unknown as ReturnType<typeof hookRunnerGlobal.getGlobalHookRunner>,
|
||||
);
|
||||
readLatestAssistantReplySpy
|
||||
.mockReset()
|
||||
.mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey));
|
||||
|
||||
@ -102,7 +102,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
|
||||
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
export function formatEnvelopeTimestamp(
|
||||
ts: number | Date | undefined,
|
||||
options?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
@ -179,7 +179,7 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
if (params.ip?.trim()) {
|
||||
parts.push(sanitizeEnvelopeHeaderPart(params.ip.trim()));
|
||||
}
|
||||
const ts = formatTimestamp(params.timestamp, resolved);
|
||||
const ts = formatEnvelopeTimestamp(params.timestamp, resolved);
|
||||
if (ts) {
|
||||
parts.push(ts);
|
||||
}
|
||||
|
||||
@ -89,8 +89,8 @@ export function createBlockReplyCoalescer(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
// When flushOnEnqueue is set (chunkMode="newline"), each enqueued payload is treated
|
||||
// as a separate paragraph and flushed immediately so delivery matches streaming boundaries.
|
||||
// When flushOnEnqueue is set, treat each enqueued payload as its own outbound block
|
||||
// and flush immediately instead of waiting for coalescing thresholds.
|
||||
if (flushOnEnqueue) {
|
||||
if (bufferText) {
|
||||
void flush({ force: true });
|
||||
|
||||
@ -44,6 +44,34 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
|
||||
expect(resolved.coalescing.idleMs).toBe(0);
|
||||
});
|
||||
|
||||
it("honors newline chunkMode for plugin channels even before the plugin registry is loaded", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
chunkMode: "newline",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
blockStreamingChunk: {
|
||||
minChars: 1,
|
||||
maxChars: 4000,
|
||||
breakPreference: "paragraph",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveEffectiveBlockStreamingConfig({
|
||||
cfg,
|
||||
provider: "bluebubbles",
|
||||
});
|
||||
|
||||
expect(resolved.chunking.flushOnParagraph).toBe(true);
|
||||
expect(resolved.coalescing.flushOnEnqueue).toBeUndefined();
|
||||
expect(resolved.coalescing.joiner).toBe("\n\n");
|
||||
});
|
||||
|
||||
it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@ -3,26 +3,22 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
|
||||
import { resolveAccountEntry } from "../../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
listDeliverableMessageChannels,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
|
||||
const getBlockChunkProviders = () =>
|
||||
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
|
||||
|
||||
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
|
||||
? (cleaned as TextChunkProvider)
|
||||
: undefined;
|
||||
const normalized = normalizeMessageChannel(provider);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized as TextChunkProvider;
|
||||
}
|
||||
|
||||
function resolveProviderChunkContext(
|
||||
@ -70,7 +66,7 @@ export type BlockStreamingCoalescing = {
|
||||
maxChars: number;
|
||||
idleMs: number;
|
||||
joiner: string;
|
||||
/** When true, the coalescer flushes the buffer on each enqueue (paragraph-boundary flush). */
|
||||
/** Internal escape hatch for transports that truly need per-enqueue flushing. */
|
||||
flushOnEnqueue?: boolean;
|
||||
};
|
||||
|
||||
@ -151,7 +147,7 @@ export function resolveEffectiveBlockStreamingConfig(params: {
|
||||
: chunking.breakPreference === "newline"
|
||||
? "\n"
|
||||
: "\n\n"),
|
||||
flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true,
|
||||
...(coalescingDefaults?.flushOnEnqueue === true ? { flushOnEnqueue: true } : {}),
|
||||
};
|
||||
|
||||
return { chunking, coalescing };
|
||||
@ -165,9 +161,9 @@ export function resolveBlockStreamingChunking(
|
||||
const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId);
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// When chunkMode="newline", the outbound delivery splits on paragraph boundaries.
|
||||
// The block chunker should flush eagerly on \n\n boundaries during streaming,
|
||||
// regardless of minChars, so each paragraph is sent as its own message.
|
||||
// When chunkMode="newline", outbound delivery prefers paragraph boundaries.
|
||||
// Keep the chunker paragraph-aware during streaming, but still let minChars
|
||||
// control when a buffered paragraph is ready to flush.
|
||||
const chunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
@ -196,7 +192,6 @@ export function resolveBlockStreamingCoalescing(
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
},
|
||||
opts?: { chunkMode?: "length" | "newline" },
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const { providerKey, providerId, textLimit } = resolveProviderChunkContext(
|
||||
cfg,
|
||||
@ -204,9 +199,6 @@ export function resolveBlockStreamingCoalescing(
|
||||
accountId,
|
||||
);
|
||||
|
||||
// Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries
|
||||
// when chunkMode="newline", matching the delivery-time splitting behavior.
|
||||
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId);
|
||||
const providerDefaults = providerId
|
||||
? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults
|
||||
: undefined;
|
||||
@ -241,6 +233,5 @@ export function resolveBlockStreamingCoalescing(
|
||||
maxChars,
|
||||
idleMs,
|
||||
joiner,
|
||||
flushOnEnqueue: chunkMode === "newline",
|
||||
};
|
||||
}
|
||||
|
||||
@ -24,6 +24,10 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
|
||||
return resolveCommandSurfaceChannel(params) === "telegram";
|
||||
}
|
||||
|
||||
export function isMatrixSurface(params: DiscordSurfaceParams): boolean {
|
||||
return resolveCommandSurfaceChannel(params) === "matrix";
|
||||
}
|
||||
|
||||
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
|
||||
const channel =
|
||||
params.ctx.OriginatingChannel ??
|
||||
|
||||
@ -120,7 +120,7 @@ type FakeBinding = {
|
||||
targetSessionKey: string;
|
||||
targetKind: "subagent" | "session";
|
||||
conversation: {
|
||||
channel: "discord" | "telegram" | "feishu";
|
||||
channel: "discord" | "matrix" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@ -245,9 +245,10 @@ function createSessionBindingCapabilities() {
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: {
|
||||
channel?: "discord" | "telegram" | "feishu";
|
||||
channel?: "discord" | "matrix" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
}
|
||||
: channel === "feishu"
|
||||
: channel === "matrix"
|
||||
? {
|
||||
channel: "feishu" as const,
|
||||
channel: "matrix" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId:
|
||||
input.placement === "child"
|
||||
? input.conversation.conversationId
|
||||
: input.conversation.parentConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
: channel === "feishu"
|
||||
? {
|
||||
channel: "feishu" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation,
|
||||
@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
|
||||
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = createMatrixRoomParams(commandBody, cfg);
|
||||
params.ctx.MessageThreadId = "$thread-root";
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "feishu",
|
||||
@ -598,6 +635,63 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "child",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Bound this thread to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-root",
|
||||
parentConversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Feishu DM ACP spawns to the current DM conversation", async () => {
|
||||
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
|
||||
|
||||
@ -654,6 +748,24 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids /acp spawn from sandboxed requester sessions", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
|
||||
@ -141,6 +141,27 @@ describe("commands-acp context", () => {
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
|
||||
});
|
||||
|
||||
it("resolves Matrix thread context from the current room and thread root", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "work",
|
||||
MessageThreadId: "$thread-root",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "matrix",
|
||||
accountId: "work",
|
||||
threadId: "$thread-root",
|
||||
conversationId: "$thread-root",
|
||||
parentConversationId: "!room:example.org",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("$thread-root");
|
||||
expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org");
|
||||
});
|
||||
|
||||
it("builds Feishu topic conversation ids from chat target + root message id", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
|
||||
@ -9,6 +9,10 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "../matrix-context.js";
|
||||
import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
|
||||
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
||||
@ -161,6 +165,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
|
||||
|
||||
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "matrix") {
|
||||
return resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (channel === "telegram") {
|
||||
const telegramConversationId = resolveTelegramConversationId({
|
||||
ctx: {
|
||||
@ -231,6 +247,18 @@ export function resolveAcpCommandParentConversationId(
|
||||
params: HandleCommandsParams,
|
||||
): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "matrix") {
|
||||
return resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (channel === "telegram") {
|
||||
return (
|
||||
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??
|
||||
|
||||
@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const senderId = commandParams.command.senderId?.trim() || "";
|
||||
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
|
||||
const conversationRef = {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId && parentConversationId !== currentConversationId
|
||||
? { parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
if (placement === "current") {
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
});
|
||||
const existingBinding = bindingService.resolveByConversation(conversationRef);
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
? existingBinding.metadata.boundBy.trim()
|
||||
@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const label = params.label || params.agentId;
|
||||
const conversationId = currentConversationId;
|
||||
|
||||
try {
|
||||
const binding = await bindingService.bind({
|
||||
targetSessionKey: params.sessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId,
|
||||
},
|
||||
conversation: conversationRef,
|
||||
placement,
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
|
||||
@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
|
||||
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
|
||||
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import {
|
||||
DISCORD_THREAD_BINDING_CHANNEL,
|
||||
MATRIX_THREAD_BINDING_CHANNEL,
|
||||
} from "../../../channels/thread-bindings-policy.js";
|
||||
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
|
||||
import { normalizeAgentId } from "../../../routing/session-key.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
|
||||
@ -168,7 +171,8 @@ function normalizeAcpOptionToken(raw: string): string {
|
||||
}
|
||||
|
||||
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
|
||||
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) {
|
||||
return "off";
|
||||
}
|
||||
const currentThreadId = resolveAcpCommandThreadId(params);
|
||||
|
||||
@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => {
|
||||
const getThreadBindingManagerMock = vi.fn();
|
||||
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setMatrixThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const sessionBindingResolveByConversationMock = vi.fn();
|
||||
@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => {
|
||||
getThreadBindingManagerMock,
|
||||
setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setThreadBindingMaxAgeBySessionKeyMock,
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
sessionBindingResolveByConversationMock,
|
||||
@ -48,6 +52,12 @@ vi.mock("../../plugins/runtime/index.js", async () => {
|
||||
setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@ -114,6 +124,29 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixThreadCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "$thread-1",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixRoomCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): Sessi
|
||||
};
|
||||
}
|
||||
|
||||
function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "default:$thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectIdleTimeoutSetReply(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
text: string,
|
||||
@ -183,6 +239,8 @@ describe("/session idle and /session max-age", () => {
|
||||
hoisted.getThreadBindingManagerMock.mockReset();
|
||||
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
|
||||
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
|
||||
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReset();
|
||||
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
|
||||
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
|
||||
@ -286,6 +344,66 @@ describe("/session idle and /session max-age", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets idle timeout for focused Matrix threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding());
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixThreadCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets max age for focused Matrix threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createMatrixBinding({ boundAt }),
|
||||
);
|
||||
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt,
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixThreadCommandParams("/session max-age 3h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Max age set to 3h");
|
||||
expect(text).toContain("2026-02-20T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("reports Telegram max-age expiry from the original bind time", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
@ -340,10 +458,20 @@ describe("/session idle and /session max-age", () => {
|
||||
const params = buildCommandTestParams("/session idle 2h", baseCfg);
|
||||
const result = await handleSessionCommand(params, true);
|
||||
expect(result?.reply?.text).toContain(
|
||||
"currently available for Discord and Telegram bound sessions",
|
||||
"currently available for Discord, Matrix, and Telegram bound sessions",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a focused Matrix thread for lifecycle updates", async () => {
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixRoomCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread");
|
||||
expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires binding owner for lifecycle updates", async () => {
|
||||
const binding = createFakeBinding({ boundBy: "owner-1" });
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
|
||||
|
||||
@ -12,10 +12,19 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
|
||||
import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js";
|
||||
import {
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveChannelAccountId,
|
||||
} from "./channel-context.js";
|
||||
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
|
||||
import { persistSessionEntry } from "./commands-session-store.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "./matrix-context.js";
|
||||
import { resolveTelegramConversationId } from "./telegram-context.js";
|
||||
|
||||
const SESSION_COMMAND_PREFIX = "/session";
|
||||
@ -55,7 +64,7 @@ function formatSessionExpiry(expiresAt: number) {
|
||||
return new Date(expiresAt).toISOString();
|
||||
}
|
||||
|
||||
function resolveTelegramBindingDurationMs(
|
||||
function resolveSessionBindingDurationMs(
|
||||
binding: SessionBindingRecord,
|
||||
key: "idleTimeoutMs" | "maxAgeMs",
|
||||
fallbackMs: number,
|
||||
@ -67,7 +76,7 @@ function resolveTelegramBindingDurationMs(
|
||||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
|
||||
function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number {
|
||||
function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number {
|
||||
const raw = binding.metadata?.lastActivityAt;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return binding.boundAt;
|
||||
@ -75,7 +84,7 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu
|
||||
return Math.max(Math.floor(raw), binding.boundAt);
|
||||
}
|
||||
|
||||
function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string {
|
||||
function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string {
|
||||
const raw = binding.metadata?.boundBy;
|
||||
return typeof raw === "string" ? raw.trim() : "";
|
||||
}
|
||||
@ -87,6 +96,46 @@ type UpdatedLifecycleBinding = {
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
function isSessionBindingRecord(
|
||||
binding: UpdatedLifecycleBinding | SessionBindingRecord,
|
||||
): binding is SessionBindingRecord {
|
||||
return "bindingId" in binding;
|
||||
}
|
||||
|
||||
function resolveUpdatedLifecycleDurationMs(
|
||||
binding: UpdatedLifecycleBinding | SessionBindingRecord,
|
||||
key: "idleTimeoutMs" | "maxAgeMs",
|
||||
): number | undefined {
|
||||
if (!isSessionBindingRecord(binding)) {
|
||||
const raw = binding[key];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
}
|
||||
if (!isSessionBindingRecord(binding)) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = binding.metadata?.[key];
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
|
||||
function toUpdatedLifecycleBinding(
|
||||
binding: UpdatedLifecycleBinding | SessionBindingRecord,
|
||||
): UpdatedLifecycleBinding {
|
||||
const lastActivityAt = isSessionBindingRecord(binding)
|
||||
? resolveSessionBindingLastActivityAt(binding)
|
||||
: Math.max(Math.floor(binding.lastActivityAt), binding.boundAt);
|
||||
return {
|
||||
boundAt: binding.boundAt,
|
||||
lastActivityAt,
|
||||
idleTimeoutMs: resolveUpdatedLifecycleDurationMs(binding, "idleTimeoutMs"),
|
||||
maxAgeMs: resolveUpdatedLifecycleDurationMs(binding, "maxAgeMs"),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveUpdatedBindingExpiry(params: {
|
||||
action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE;
|
||||
bindings: UpdatedLifecycleBinding[];
|
||||
@ -363,12 +412,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
}
|
||||
|
||||
const onDiscord = isDiscordSurface(params);
|
||||
const onMatrix = isMatrixSurface(params);
|
||||
const onTelegram = isTelegramSurface(params);
|
||||
if (!onDiscord && !onTelegram) {
|
||||
if (!onDiscord && !onMatrix && !onTelegram) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.",
|
||||
text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -377,6 +427,30 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
const sessionBindingService = getSessionBindingService();
|
||||
const threadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
const matrixConversationId = onMatrix
|
||||
? resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const matrixParentConversationId = onMatrix
|
||||
? resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined;
|
||||
const channelRuntime = getChannelRuntime();
|
||||
|
||||
@ -400,6 +474,17 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
conversationId: telegramConversationId,
|
||||
})
|
||||
: null;
|
||||
const matrixBinding =
|
||||
onMatrix && matrixConversationId
|
||||
? sessionBindingService.resolveByConversation({
|
||||
channel: "matrix",
|
||||
accountId,
|
||||
conversationId: matrixConversationId,
|
||||
...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId
|
||||
? { parentConversationId: matrixParentConversationId }
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
if (onDiscord && !discordBinding) {
|
||||
if (onDiscord && !threadId) {
|
||||
return {
|
||||
@ -414,6 +499,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
reply: { text: "ℹ️ This thread is not currently focused." },
|
||||
};
|
||||
}
|
||||
if (onMatrix && !matrixBinding) {
|
||||
if (!threadId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ This thread is not currently focused." },
|
||||
};
|
||||
}
|
||||
if (onTelegram && !telegramBinding) {
|
||||
if (!telegramConversationId) {
|
||||
return {
|
||||
@ -434,28 +533,33 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
record: discordBinding!,
|
||||
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
|
||||
})
|
||||
: resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000);
|
||||
: resolveSessionBindingDurationMs(
|
||||
(onMatrix ? matrixBinding : telegramBinding)!,
|
||||
"idleTimeoutMs",
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
const idleExpiresAt = onDiscord
|
||||
? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({
|
||||
record: discordBinding!,
|
||||
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
|
||||
})
|
||||
: idleTimeoutMs > 0
|
||||
? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs
|
||||
? resolveSessionBindingLastActivityAt((onMatrix ? matrixBinding : telegramBinding)!) +
|
||||
idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeMs = onDiscord
|
||||
? channelRuntime.discord.threadBindings.resolveMaxAgeMs({
|
||||
record: discordBinding!,
|
||||
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
|
||||
})
|
||||
: resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0);
|
||||
: resolveSessionBindingDurationMs((onMatrix ? matrixBinding : telegramBinding)!, "maxAgeMs", 0);
|
||||
const maxAgeExpiresAt = onDiscord
|
||||
? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({
|
||||
record: discordBinding!,
|
||||
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
|
||||
})
|
||||
: maxAgeMs > 0
|
||||
? telegramBinding!.boundAt + maxAgeMs
|
||||
? (onMatrix ? matrixBinding : telegramBinding)!.boundAt + maxAgeMs
|
||||
: undefined;
|
||||
|
||||
const durationArgRaw = tokens.slice(1).join("");
|
||||
@ -500,14 +604,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
const boundBy = onDiscord
|
||||
? discordBinding!.boundBy
|
||||
: resolveTelegramBindingBoundBy(telegramBinding!);
|
||||
: resolveSessionBindingBoundBy((onMatrix ? matrixBinding : telegramBinding)!);
|
||||
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: onDiscord
|
||||
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
|
||||
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
|
||||
: onMatrix
|
||||
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
|
||||
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -536,6 +642,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
maxAgeMs: durationMs,
|
||||
});
|
||||
}
|
||||
if (onMatrix) {
|
||||
return action === SESSION_ACTION_IDLE
|
||||
? channelRuntime.matrix.threadBindings.setIdleTimeoutBySessionKey({
|
||||
targetSessionKey: matrixBinding!.targetSessionKey,
|
||||
accountId,
|
||||
idleTimeoutMs: durationMs,
|
||||
})
|
||||
: channelRuntime.matrix.threadBindings.setMaxAgeBySessionKey({
|
||||
targetSessionKey: matrixBinding!.targetSessionKey,
|
||||
accountId,
|
||||
maxAgeMs: durationMs,
|
||||
});
|
||||
}
|
||||
return action === SESSION_ACTION_IDLE
|
||||
? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({
|
||||
targetSessionKey: telegramBinding!.targetSessionKey,
|
||||
@ -574,7 +693,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
|
||||
const nextExpiry = resolveUpdatedBindingExpiry({
|
||||
action,
|
||||
bindings: updatedBindings,
|
||||
bindings: updatedBindings.map((binding) => toUpdatedLifecycleBinding(binding)),
|
||||
});
|
||||
const expiryLabel =
|
||||
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)
|
||||
|
||||
@ -103,6 +103,31 @@ function createTelegramTopicCommandParams(commandBody: string) {
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "$thread-1",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createSessionBindingRecord(
|
||||
overrides?: Partial<SessionBindingRecord>,
|
||||
): SessionBindingRecord {
|
||||
@ -144,7 +169,13 @@ async function focusCodexAcp(
|
||||
hoisted.sessionBindingBindMock.mockImplementation(
|
||||
async (input: {
|
||||
targetSessionKey: string;
|
||||
conversation: { channel: string; accountId: string; conversationId: string };
|
||||
placement: "current" | "child";
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}) =>
|
||||
createSessionBindingRecord({
|
||||
@ -152,7 +183,11 @@ async function focusCodexAcp(
|
||||
conversation: {
|
||||
channel: input.conversation.channel,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: input.conversation.conversationId,
|
||||
conversationId:
|
||||
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
|
||||
...(input.conversation.parentConversationId
|
||||
? { parentConversationId: input.conversation.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
metadata: {
|
||||
boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
|
||||
@ -220,6 +255,51 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
|
||||
|
||||
expect(result?.reply?.text).toContain("created thread thread-created and bound it");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "child",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
conversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnSubagentSessions=true");
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("/focus includes ACP session identifiers in intro text when available", async () => {
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
@ -283,6 +363,36 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/unfocus removes an active Matrix thread binding for the binding owner", async () => {
|
||||
const params = createMatrixThreadCommandParams("/unfocus");
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBindingRecord({
|
||||
bindingId: "default:matrix-thread-1",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
metadata: { boundBy: "user-1" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Thread unfocused");
|
||||
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
});
|
||||
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({
|
||||
bindingId: "default:matrix-thread-1",
|
||||
reason: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("/focus rejects rebinding when the thread is focused by another user", async () => {
|
||||
const result = await focusCodexAcp(undefined, {
|
||||
existingBinding: createSessionBindingRecord({
|
||||
@ -401,6 +511,6 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
it("/focus rejects unsupported channels", async () => {
|
||||
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("only available on Discord and Telegram");
|
||||
expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram");
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,14 +8,22 @@ import {
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../../../channels/thread-bindings-messages.js";
|
||||
import {
|
||||
formatThreadBindingDisabledError,
|
||||
formatThreadBindingSpawnDisabledError,
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "../../../channels/thread-bindings-policy.js";
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "../matrix-context.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveChannelAccountId,
|
||||
resolveCommandSurfaceChannel,
|
||||
@ -26,9 +34,10 @@ import {
|
||||
} from "./shared.js";
|
||||
|
||||
type FocusBindingContext = {
|
||||
channel: "discord" | "telegram";
|
||||
channel: "discord" | "matrix" | "telegram";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
placement: "current" | "child";
|
||||
labelNoun: "thread" | "conversation";
|
||||
};
|
||||
@ -65,6 +74,41 @@ function resolveFocusBindingContext(
|
||||
labelNoun: "conversation",
|
||||
};
|
||||
}
|
||||
if (isMatrixSurface(params)) {
|
||||
const conversationId = resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
const parentConversationId = resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
const currentThreadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
return {
|
||||
channel: "matrix",
|
||||
accountId: resolveChannelAccountId(params),
|
||||
conversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
placement: currentThreadId ? "current" : "child",
|
||||
labelNoun: "thread",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -73,8 +117,8 @@ export async function handleSubagentsFocusAction(
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
const channel = resolveCommandSurfaceChannel(params);
|
||||
if (channel !== "discord" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /focus is only available on Discord and Telegram.");
|
||||
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
|
||||
}
|
||||
|
||||
const token = restTokens.join(" ").trim();
|
||||
@ -89,7 +133,12 @@ export async function handleSubagentsFocusAction(
|
||||
accountId,
|
||||
});
|
||||
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
|
||||
const label = channel === "discord" ? "Discord thread" : "Telegram conversation";
|
||||
const label =
|
||||
channel === "discord"
|
||||
? "Discord thread"
|
||||
: channel === "matrix"
|
||||
? "Matrix thread"
|
||||
: "Telegram conversation";
|
||||
return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`);
|
||||
}
|
||||
|
||||
@ -105,14 +154,48 @@ export async function handleSubagentsFocusAction(
|
||||
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
|
||||
);
|
||||
}
|
||||
if (channel === "matrix") {
|
||||
return stopWithText("⚠️ Could not resolve a Matrix room for /focus.");
|
||||
}
|
||||
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
|
||||
}
|
||||
|
||||
if (channel === "matrix") {
|
||||
const spawnPolicy = resolveThreadBindingSpawnPolicy({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId: bindingContext.accountId,
|
||||
kind: "subagent",
|
||||
});
|
||||
if (!spawnPolicy.enabled) {
|
||||
return stopWithText(
|
||||
`⚠️ ${formatThreadBindingDisabledError({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
kind: "subagent",
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
if (bindingContext.placement === "child" && !spawnPolicy.spawnEnabled) {
|
||||
return stopWithText(
|
||||
`⚠️ ${formatThreadBindingSpawnDisabledError({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
kind: "subagent",
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
...(bindingContext.parentConversationId &&
|
||||
bindingContext.parentConversationId !== bindingContext.conversationId
|
||||
? { parentConversationId: bindingContext.parentConversationId }
|
||||
: {}),
|
||||
});
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
@ -143,6 +226,10 @@ export async function handleSubagentsFocusAction(
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
...(bindingContext.parentConversationId &&
|
||||
bindingContext.parentConversationId !== bindingContext.conversationId
|
||||
? { parentConversationId: bindingContext.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
placement: bindingContext.placement,
|
||||
metadata: {
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "../matrix-context.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveChannelAccountId,
|
||||
resolveCommandSurfaceChannel,
|
||||
@ -15,8 +20,8 @@ export async function handleSubagentsUnfocusAction(
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params } = ctx;
|
||||
const channel = resolveCommandSurfaceChannel(params);
|
||||
if (channel !== "discord" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord and Telegram.");
|
||||
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
|
||||
}
|
||||
|
||||
const accountId = resolveChannelAccountId(params);
|
||||
@ -30,13 +35,43 @@ export async function handleSubagentsUnfocusAction(
|
||||
if (isTelegramSurface(params)) {
|
||||
return resolveTelegramConversationId(params);
|
||||
}
|
||||
if (isMatrixSurface(params)) {
|
||||
return resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const parentConversationId = (() => {
|
||||
if (!isMatrixSurface(params)) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
if (!conversationId) {
|
||||
if (channel === "discord") {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
|
||||
}
|
||||
if (channel === "matrix") {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Matrix thread.");
|
||||
}
|
||||
return stopWithText(
|
||||
"⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.",
|
||||
);
|
||||
@ -46,12 +81,17 @@ export async function handleSubagentsUnfocusAction(
|
||||
channel,
|
||||
accountId,
|
||||
conversationId,
|
||||
...(parentConversationId && parentConversationId !== conversationId
|
||||
? { parentConversationId }
|
||||
: {}),
|
||||
});
|
||||
if (!binding) {
|
||||
return stopWithText(
|
||||
channel === "discord"
|
||||
? "ℹ️ This thread is not currently focused."
|
||||
: "ℹ️ This conversation is not currently focused.",
|
||||
: channel === "matrix"
|
||||
? "ℹ️ This thread is not currently focused."
|
||||
: "ℹ️ This conversation is not currently focused.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,7 +102,9 @@ export async function handleSubagentsUnfocusAction(
|
||||
return stopWithText(
|
||||
channel === "discord"
|
||||
? `⚠️ Only ${boundBy} can unfocus this thread.`
|
||||
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
|
||||
: channel === "matrix"
|
||||
? `⚠️ Only ${boundBy} can unfocus this thread.`
|
||||
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -71,6 +113,8 @@ export async function handleSubagentsUnfocusAction(
|
||||
reason: "manual",
|
||||
});
|
||||
return stopWithText(
|
||||
channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.",
|
||||
channel === "discord" || channel === "matrix"
|
||||
? "✅ Thread unfocused."
|
||||
: "✅ Conversation unfocused.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
} from "../../../shared/subagents-format.js";
|
||||
import {
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveCommandSurfaceChannel,
|
||||
resolveDiscordAccountId,
|
||||
@ -47,6 +48,7 @@ import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
export { extractAssistantText, stripToolMessages };
|
||||
export {
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveCommandSurfaceChannel,
|
||||
resolveDiscordAccountId,
|
||||
|
||||
@ -21,6 +21,7 @@ import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import { hasControlCommand } from "../command-detection.js";
|
||||
import { resolveEnvelopeFormatOptions } from "../envelope.js";
|
||||
import { buildInboundMediaNote } from "../media-note.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import {
|
||||
@ -292,6 +293,7 @@ export async function runPreparedReply(
|
||||
isNewSession &&
|
||||
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
|
||||
const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody;
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const inboundUserContext = buildInboundUserContextPrefix(
|
||||
isNewSession
|
||||
? {
|
||||
@ -301,6 +303,7 @@ export async function runPreparedReply(
|
||||
: {}),
|
||||
}
|
||||
: { ...sessionCtx, ThreadStarterBody: undefined },
|
||||
envelopeOptions,
|
||||
);
|
||||
const baseBodyForPrompt = isBareSessionReset
|
||||
? baseBodyFinal
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../test-utils/env.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js";
|
||||
|
||||
@ -217,6 +218,25 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
expect(conversationInfo["timestamp"]).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("honors envelope user timezone for conversation timestamps", () => {
|
||||
withEnv({ TZ: "America/Los_Angeles" }, () => {
|
||||
const text = buildInboundUserContextPrefix(
|
||||
{
|
||||
ChatType: "group",
|
||||
MessageSid: "msg-with-user-tz",
|
||||
Timestamp: Date.UTC(2026, 2, 19, 0, 0),
|
||||
} as TemplateContext,
|
||||
{
|
||||
timezone: "user",
|
||||
userTimezone: "Asia/Tokyo",
|
||||
},
|
||||
);
|
||||
|
||||
const conversationInfo = parseConversationInfoPayload(text);
|
||||
expect(conversationInfo["timestamp"]).toBe("Thu 2026-03-19 09:00 GMT+9");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits invalid timestamps instead of throwing", () => {
|
||||
expect(() =>
|
||||
buildInboundUserContextPrefix({
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { normalizeChatType } from "../../channels/chat-type.js";
|
||||
import { resolveSenderLabel } from "../../channels/sender-label.js";
|
||||
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
||||
import type { EnvelopeFormatOptions } from "../envelope.js";
|
||||
import { formatEnvelopeTimestamp } from "../envelope.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
|
||||
function safeTrim(value: unknown): string | undefined {
|
||||
@ -11,24 +12,14 @@ function safeTrim(value: unknown): string | undefined {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function formatConversationTimestamp(value: unknown): string | undefined {
|
||||
function formatConversationTimestamp(
|
||||
value: unknown,
|
||||
envelope?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
const formatted = formatZonedTimestamp(date);
|
||||
if (!formatted) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
|
||||
return weekday ? `${weekday} ${formatted}` : formatted;
|
||||
} catch {
|
||||
return formatted;
|
||||
}
|
||||
return formatEnvelopeTimestamp(value, envelope);
|
||||
}
|
||||
|
||||
function resolveInboundChannel(ctx: TemplateContext): string | undefined {
|
||||
@ -81,7 +72,10 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
export function buildInboundUserContextPrefix(
|
||||
ctx: TemplateContext,
|
||||
envelope?: EnvelopeFormatOptions,
|
||||
): string {
|
||||
const blocks: string[] = [];
|
||||
const chatType = normalizeChatType(ctx.ChatType);
|
||||
const isDirect = !chatType || chatType === "direct";
|
||||
@ -94,7 +88,7 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
const messageId = safeTrim(ctx.MessageSid);
|
||||
const messageIdFull = safeTrim(ctx.MessageSidFull);
|
||||
const resolvedMessageId = messageId ?? messageIdFull;
|
||||
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
|
||||
const timestampStr = formatConversationTimestamp(ctx.Timestamp, envelope);
|
||||
|
||||
const conversationInfo = {
|
||||
message_id: shouldIncludeConversationInfo ? resolvedMessageId : undefined,
|
||||
|
||||
@ -675,6 +675,39 @@ describe("block reply coalescer", () => {
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("keeps buffering newline-style chunks until minChars is reached", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||
minChars: 25,
|
||||
maxChars: 2000,
|
||||
idleMs: 50,
|
||||
joiner: "\n\n",
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("force flushes buffered newline-style chunks even below minChars", async () => {
|
||||
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||
minChars: 100,
|
||||
maxChars: 2000,
|
||||
idleMs: 50,
|
||||
joiner: "\n\n",
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
await coalescer.flush({ force: true });
|
||||
|
||||
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes immediately per enqueue when flushOnEnqueue is set", async () => {
|
||||
const cases = [
|
||||
{
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, vi } from "vitest";
|
||||
import {
|
||||
__testing as discordThreadBindingTesting,
|
||||
createThreadBindingManager as createDiscordThreadBindingManager,
|
||||
} from "../../../../extensions/discord/runtime-api.js";
|
||||
import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js";
|
||||
import { createMatrixThreadBindingManager } from "../../../../extensions/matrix/api.js";
|
||||
import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
@ -126,7 +130,7 @@ type DirectoryContractEntry = {
|
||||
type SessionBindingContractEntry = {
|
||||
id: string;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
@ -136,6 +140,7 @@ function expectResolvedSessionBinding(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
targetSessionKey: string;
|
||||
}) {
|
||||
expect(
|
||||
@ -143,6 +148,7 @@ function expectResolvedSessionBinding(params: {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
@ -589,6 +595,24 @@ const baseSessionBindingCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
async function createContractMatrixThreadBindingManager() {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-contract-thread-bindings-"));
|
||||
return await createMatrixThreadBindingManager({
|
||||
accountId: "ops",
|
||||
auth: {
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
client: {} as never,
|
||||
stateDir,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
enableSweeper: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
|
||||
{
|
||||
id: "discord",
|
||||
@ -708,6 +732,61 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "matrix",
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
await createContractMatrixThreadBindingManager();
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createContractMatrixThreadBindingManager();
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:matrix:subagent:child-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
conversationId: "!room:example",
|
||||
},
|
||||
placement: "child",
|
||||
metadata: {
|
||||
label: "codex-matrix",
|
||||
introText: "intro root",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example",
|
||||
targetSessionKey: "agent:matrix:subagent:child-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
const manager = await createContractMatrixThreadBindingManager();
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
expectedCapabilities: {
|
||||
|
||||
@ -1,15 +1,32 @@
|
||||
import { beforeEach, describe } from "vitest";
|
||||
import { beforeEach, describe, vi } from "vitest";
|
||||
import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
|
||||
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/api.js";
|
||||
import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js";
|
||||
import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js";
|
||||
import { sessionBindingContractRegistry } from "./registry.js";
|
||||
import { installSessionBindingContractSuite } from "./suites.js";
|
||||
|
||||
vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../../extensions/matrix/src/matrix/send.js")
|
||||
>("../../../../extensions/matrix/src/matrix/send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageMatrix: vi.fn(
|
||||
async (_to: string, _message: string, opts?: { threadId?: string }) => ({
|
||||
messageId: opts?.threadId ? "$reply" : "$root",
|
||||
roomId: "!room:example",
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
discordThreadBindingTesting.resetThreadBindingsForTests();
|
||||
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
resetMatrixThreadBindingsForTests();
|
||||
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
|
||||
});
|
||||
|
||||
|
||||
@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: {
|
||||
}
|
||||
|
||||
export function installSessionBindingContractSuite(params: {
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
}) {
|
||||
it("registers the expected session binding capabilities", () => {
|
||||
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
|
||||
it("registers the expected session binding capabilities", async () => {
|
||||
expect(await params.getCapabilities()).toEqual(params.expectedCapabilities);
|
||||
});
|
||||
|
||||
it("binds and resolves a session binding through the shared service", async () => {
|
||||
|
||||
@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
"set-profile",
|
||||
"set-presence",
|
||||
"download-file",
|
||||
] as const;
|
||||
|
||||
@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
|
||||
export const DISCORD_THREAD_BINDING_CHANNEL = "discord";
|
||||
export const MATRIX_THREAD_BINDING_CHANNEL = "matrix";
|
||||
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
|
||||
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
|
||||
|
||||
@ -127,8 +128,9 @@ export function resolveThreadBindingSpawnPolicy(params: {
|
||||
const spawnFlagKey = resolveSpawnFlagKey(params.kind);
|
||||
const spawnEnabledRaw =
|
||||
normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]);
|
||||
// Non-Discord channels currently have no dedicated spawn gate config keys.
|
||||
const spawnEnabled = spawnEnabledRaw ?? channel !== DISCORD_THREAD_BINDING_CHANNEL;
|
||||
const spawnEnabled =
|
||||
spawnEnabledRaw ??
|
||||
(channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL);
|
||||
return {
|
||||
channel,
|
||||
accountId,
|
||||
@ -183,6 +185,9 @@ export function formatThreadBindingDisabledError(params: {
|
||||
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL) {
|
||||
return "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).";
|
||||
}
|
||||
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL) {
|
||||
return "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).";
|
||||
}
|
||||
return `Thread bindings are disabled for ${params.channel} (set session.threadBindings.enabled=true to enable).`;
|
||||
}
|
||||
|
||||
@ -197,5 +202,11 @@ export function formatThreadBindingSpawnDisabledError(params: {
|
||||
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") {
|
||||
return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).";
|
||||
}
|
||||
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "acp") {
|
||||
return "Matrix thread-bound ACP spawns are disabled for this account (set channels.matrix.threadBindings.spawnAcpSessions=true to enable).";
|
||||
}
|
||||
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "subagent") {
|
||||
return "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable).";
|
||||
}
|
||||
return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`;
|
||||
}
|
||||
|
||||
@ -174,7 +174,7 @@ describe("registerAgentCommands", () => {
|
||||
"--agent",
|
||||
"ops",
|
||||
"--bind",
|
||||
"matrix-js:ops",
|
||||
"matrix:ops",
|
||||
"--bind",
|
||||
"telegram",
|
||||
"--json",
|
||||
@ -182,7 +182,7 @@ describe("registerAgentCommands", () => {
|
||||
expect(agentsBindCommandMock).toHaveBeenCalledWith(
|
||||
{
|
||||
agent: "ops",
|
||||
bind: ["matrix-js:ops", "telegram"],
|
||||
bind: ["matrix:ops", "telegram"],
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
|
||||
@ -15,9 +15,9 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getChannelPlugin: (channel: string) => {
|
||||
if (channel === "matrix-js") {
|
||||
if (channel === "matrix") {
|
||||
return {
|
||||
id: "matrix-js",
|
||||
id: "matrix",
|
||||
setup: {
|
||||
resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(),
|
||||
},
|
||||
@ -26,8 +26,8 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
|
||||
return actual.getChannelPlugin(channel);
|
||||
},
|
||||
normalizeChannelId: (channel: string) => {
|
||||
if (channel.trim().toLowerCase() === "matrix-js") {
|
||||
return "matrix-js";
|
||||
if (channel.trim().toLowerCase() === "matrix") {
|
||||
return "matrix";
|
||||
}
|
||||
return actual.normalizeChannelId(channel);
|
||||
},
|
||||
@ -52,7 +52,7 @@ describe("agents bind/unbind commands", () => {
|
||||
...baseConfigSnapshot,
|
||||
config: {
|
||||
bindings: [
|
||||
{ agentId: "main", match: { channel: "matrix-js" } },
|
||||
{ agentId: "main", match: { channel: "matrix" } },
|
||||
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
|
||||
],
|
||||
},
|
||||
@ -60,7 +60,7 @@ describe("agents bind/unbind commands", () => {
|
||||
|
||||
await agentsBindingsCommand({}, runtime);
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js"));
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix"));
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ops <- telegram accountId=work"),
|
||||
);
|
||||
@ -76,23 +76,29 @@ describe("agents bind/unbind commands", () => {
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
|
||||
bindings: [{ type: "route", agentId: "main", match: { channel: "telegram" } }],
|
||||
}),
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults matrix-js accountId to the target agent id when omitted", async () => {
|
||||
it("defaults matrix accountId to the target agent id when omitted", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: {},
|
||||
});
|
||||
|
||||
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
|
||||
await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime);
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
|
||||
bindings: [
|
||||
{
|
||||
type: "route",
|
||||
agentId: "main",
|
||||
match: { channel: "matrix", accountId: "main" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
@ -123,7 +129,7 @@ describe("agents bind/unbind commands", () => {
|
||||
config: {
|
||||
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
|
||||
bindings: [
|
||||
{ agentId: "main", match: { channel: "matrix-js" } },
|
||||
{ agentId: "main", match: { channel: "matrix" } },
|
||||
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
|
||||
],
|
||||
},
|
||||
@ -133,7 +139,7 @@ describe("agents bind/unbind commands", () => {
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bindings: [{ agentId: "main", match: { channel: "matrix-js" } }],
|
||||
bindings: [{ agentId: "main", match: { channel: "matrix" } }],
|
||||
}),
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
|
||||
@ -350,14 +350,15 @@ export async function channelsAddCommand(
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
|
||||
if (plugin.setup.afterAccountConfigWritten) {
|
||||
const setup = plugin.setup;
|
||||
if (setup?.afterAccountConfigWritten) {
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: [
|
||||
{
|
||||
channel,
|
||||
accountId,
|
||||
run: async ({ cfg: writtenCfg, runtime: hookRuntime }) =>
|
||||
await plugin.setup.afterAccountConfigWritten?.({
|
||||
await setup.afterAccountConfigWritten?.({
|
||||
previousCfg: cfg,
|
||||
cfg: writtenCfg,
|
||||
accountId,
|
||||
|
||||
@ -106,10 +106,16 @@ export async function channelsRemoveCommand(
|
||||
if (resolvedPluginState?.configChanged) {
|
||||
cfg = resolvedPluginState.cfg;
|
||||
}
|
||||
channel = resolvedPluginState?.channelId ?? channel;
|
||||
const plugin = resolvedPluginState?.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
|
||||
const resolvedChannel = resolvedPluginState?.channelId ?? channel;
|
||||
if (!resolvedChannel) {
|
||||
runtime.error(`Unknown channel: ${rawChannel}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
channel = resolvedChannel;
|
||||
const plugin = resolvedPluginState?.plugin ?? getChannelPlugin(resolvedChannel);
|
||||
if (!plugin) {
|
||||
runtime.error(`Unknown channel: ${channel}`);
|
||||
runtime.error(`Unknown channel: ${resolvedChannel}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
@ -203,6 +204,250 @@ describe("doctor config flow", () => {
|
||||
).toBe("existing-session");
|
||||
});
|
||||
|
||||
it("previews Matrix legacy sync-store migration in read-only mode", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"next_batch":"s1"}',
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
|
||||
const warning = noteSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[1] === "Doctor warnings" &&
|
||||
String(call[0]).includes("Matrix plugin upgraded in place."),
|
||||
);
|
||||
expect(warning?.[0]).toContain("Legacy sync store:");
|
||||
expect(warning?.[0]).toContain(
|
||||
'Run "openclaw doctor --fix" to migrate this Matrix state now.',
|
||||
);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("previews Matrix encrypted-state migration in read-only mode", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(accountRoot, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
|
||||
const warning = noteSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[1] === "Doctor warnings" &&
|
||||
String(call[0]).includes("Matrix encrypted-state migration is pending"),
|
||||
);
|
||||
expect(warning?.[0]).toContain("Legacy crypto store:");
|
||||
expect(warning?.[0]).toContain("New recovery key file:");
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates Matrix legacy state on doctor repair", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"next_batch":"s1"}',
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true, repair: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
const migratedRoot = path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
"default",
|
||||
"matrix.example.org__bot_example.org",
|
||||
);
|
||||
const migratedChildren = await fs.readdir(migratedRoot);
|
||||
expect(migratedChildren.length).toBe(1);
|
||||
expect(
|
||||
await fs
|
||||
.access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await fs
|
||||
.access(path.join(stateDir, "matrix", "bot-storage.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
(call) =>
|
||||
call[1] === "Doctor changes" &&
|
||||
String(call[0]).includes("Matrix plugin upgraded in place."),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true, repair: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
const snapshotDir = path.join(home, "Backups", "openclaw-migrations");
|
||||
const snapshotEntries = await fs.readdir(snapshotDir);
|
||||
expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true);
|
||||
|
||||
const marker = JSON.parse(
|
||||
await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"),
|
||||
) as {
|
||||
archivePath: string;
|
||||
};
|
||||
expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations"));
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when Matrix is installed from a stale custom path", async () => {
|
||||
const doctorWarnings = await collectDoctorWarnings({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-matrix-missing",
|
||||
installPath: "/tmp/openclaw-matrix-missing",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
doctorWarnings.some(
|
||||
(line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when Matrix is installed from an existing custom path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const pluginPath = path.join(home, "matrix-plugin");
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
|
||||
const doctorWarnings = await collectDoctorWarnings({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: pluginPath,
|
||||
installPath: pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
doctorWarnings.some((line) => line.includes("Matrix is installed from a custom path")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
doctorWarnings.some((line) => line.includes("will not automatically replace that plugin")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("notes legacy browser extension migration changes", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
|
||||
@ -26,6 +26,23 @@ import {
|
||||
isTrustedSafeBinPath,
|
||||
normalizeTrustedSafeBinDirs,
|
||||
} from "../infra/exec-safe-bin-trust.js";
|
||||
import {
|
||||
autoPrepareLegacyMatrixCrypto,
|
||||
detectLegacyMatrixCrypto,
|
||||
} from "../infra/matrix-legacy-crypto.js";
|
||||
import {
|
||||
autoMigrateLegacyMatrixState,
|
||||
detectLegacyMatrixState,
|
||||
} from "../infra/matrix-legacy-state.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "../infra/matrix-migration-snapshot.js";
|
||||
import {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
} from "../infra/plugin-install-path-warnings.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
|
||||
import {
|
||||
@ -312,6 +329,56 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo
|
||||
return hits;
|
||||
}
|
||||
|
||||
function formatMatrixLegacyStatePreview(
|
||||
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
|
||||
): string {
|
||||
return [
|
||||
"- Matrix plugin upgraded in place.",
|
||||
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
|
||||
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
|
||||
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
|
||||
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatMatrixLegacyCryptoPreview(
|
||||
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
|
||||
): string[] {
|
||||
const notes: string[] = [];
|
||||
for (const warning of detection.warnings) {
|
||||
notes.push(`- ${warning}`);
|
||||
}
|
||||
for (const plan of detection.plans) {
|
||||
notes.push(
|
||||
[
|
||||
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
|
||||
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
|
||||
`- New recovery key file: ${plan.recoveryKeyPath}`,
|
||||
`- Migration state file: ${plan.statePath}`,
|
||||
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: cfg.plugins?.installs?.matrix,
|
||||
});
|
||||
if (!issue) {
|
||||
return [];
|
||||
}
|
||||
return formatPluginInstallPathIssue({
|
||||
issue,
|
||||
pluginLabel: "Matrix",
|
||||
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
|
||||
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
|
||||
formatCommand: formatCliCommand,
|
||||
}).map((entry) => `- ${entry}`);
|
||||
}
|
||||
|
||||
async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
@ -1699,6 +1766,110 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const matrixLegacyState = detectLegacyMatrixState({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const matrixLegacyCrypto = detectLegacyMatrixCrypto({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const pendingMatrixMigration = hasPendingMatrixMigration({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const actionableMatrixMigration = hasActionableMatrixMigration({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (shouldRepair) {
|
||||
let matrixSnapshotReady = true;
|
||||
if (actionableMatrixMigration) {
|
||||
try {
|
||||
const snapshot = await maybeCreateMatrixMigrationSnapshot({
|
||||
trigger: "doctor-fix",
|
||||
env: process.env,
|
||||
});
|
||||
note(
|
||||
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
|
||||
"Doctor changes",
|
||||
);
|
||||
} catch (err) {
|
||||
matrixSnapshotReady = false;
|
||||
note(
|
||||
`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`,
|
||||
"Doctor warnings",
|
||||
);
|
||||
note(
|
||||
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
} else if (pendingMatrixMigration) {
|
||||
note(
|
||||
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
if (matrixSnapshotReady) {
|
||||
const matrixStateRepair = await autoMigrateLegacyMatrixState({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (matrixStateRepair.changes.length > 0) {
|
||||
note(
|
||||
[
|
||||
"Matrix plugin upgraded in place.",
|
||||
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
|
||||
"- No user action required.",
|
||||
].join("\n"),
|
||||
"Doctor changes",
|
||||
);
|
||||
}
|
||||
if (matrixStateRepair.warnings.length > 0) {
|
||||
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
|
||||
}
|
||||
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (matrixCryptoRepair.changes.length > 0) {
|
||||
note(
|
||||
[
|
||||
"Matrix encrypted-state migration prepared.",
|
||||
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
|
||||
].join("\n"),
|
||||
"Doctor changes",
|
||||
);
|
||||
}
|
||||
if (matrixCryptoRepair.warnings.length > 0) {
|
||||
note(
|
||||
matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"),
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (matrixLegacyState) {
|
||||
if ("warning" in matrixLegacyState) {
|
||||
note(`- ${matrixLegacyState.warning}`, "Doctor warnings");
|
||||
} else {
|
||||
note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!shouldRepair &&
|
||||
(matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0)
|
||||
) {
|
||||
for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) {
|
||||
note(preview, "Doctor warnings");
|
||||
}
|
||||
}
|
||||
|
||||
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate);
|
||||
if (matrixInstallWarnings.length > 0) {
|
||||
note(matrixInstallWarnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
|
||||
const missingDefaultAccountBindingWarnings =
|
||||
collectMissingDefaultAccountBindingWarnings(candidate);
|
||||
if (missingDefaultAccountBindingWarnings.length > 0) {
|
||||
|
||||
@ -110,6 +110,7 @@ export const autoMigrateLegacyStateDir = vi.fn().mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}) as unknown as MockFn;
|
||||
export const runStartupMatrixMigration = vi.fn().mockResolvedValue(undefined) as unknown as MockFn;
|
||||
|
||||
function createLegacyStateMigrationDetectionResult(params?: {
|
||||
hasLegacySessions?: boolean;
|
||||
@ -299,6 +300,10 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
runLegacyStateMigrations,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server-startup-matrix-migration.js", () => ({
|
||||
runStartupMatrixMigration,
|
||||
}));
|
||||
|
||||
export function mockDoctorConfigSnapshot(
|
||||
params: {
|
||||
config?: Record<string, unknown>;
|
||||
@ -393,6 +398,7 @@ beforeEach(() => {
|
||||
serviceRestart.mockReset().mockResolvedValue(undefined);
|
||||
serviceUninstall.mockReset().mockResolvedValue(undefined);
|
||||
callGateway.mockReset().mockRejectedValue(new Error("gateway closed"));
|
||||
runStartupMatrixMigration.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
originalIsTTY = process.stdin.isTTY;
|
||||
setStdinTty(true);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user