Matrix: fix Jiti runtime API boundary

This commit is contained in:
Gustavo Madeira Santana
2026-03-19 11:39:59 -04:00
parent 5a41229a6d
commit ff6541f69d
8 changed files with 273 additions and 233 deletions

View File

@@ -1,14 +1,4 @@
export * from "openclaw/plugin-sdk/matrix";
// Keep the external runtime API light so Jiti callers can resolve Matrix config
// helpers without traversing the full plugin-sdk/runtime graph.
export * from "./src/auth-precedence.js";
export {
findMatrixAccountEntry,
hashMatrixAccessToken,
listMatrixEnvAccountIds,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixCredentialsFilename,
resolveMatrixEnvAccountToken,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} from "./helper-api.js";
export * from "./helper-api.js";

View File

@@ -17,14 +17,6 @@ import {
} from "openclaw/plugin-sdk/channel-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
type ChannelPlugin,
} from "../runtime-api.js";
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
import {
@@ -44,6 +36,14 @@ import {
resolveMatrixDirectUserId,
resolveMatrixTargetIdentity,
} from "./matrix/target-ids.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
type ChannelPlugin,
} from "./runtime-api.js";
import { getMatrixRuntime } from "./runtime.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import { matrixSetupAdapter } from "./setup-core.js";

View File

@@ -0,0 +1,225 @@
import type {
BindingTargetKind,
SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-runtime";
export type MatrixThreadBindingTargetKind = "subagent" | "acp";
export type MatrixThreadBindingRecord = {
accountId: string;
conversationId: string;
parentConversationId?: string;
targetKind: MatrixThreadBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
export type MatrixThreadBindingManager = {
accountId: string;
getIdleTimeoutMs: () => number;
getMaxAgeMs: () => number;
getByConversation: (params: {
conversationId: string;
parentConversationId?: string;
}) => MatrixThreadBindingRecord | undefined;
listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[];
listBindings: () => MatrixThreadBindingRecord[];
touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null;
setIdleTimeoutBySessionKey: (params: {
targetSessionKey: string;
idleTimeoutMs: number;
}) => MatrixThreadBindingRecord[];
setMaxAgeBySessionKey: (params: {
targetSessionKey: string;
maxAgeMs: number;
}) => MatrixThreadBindingRecord[];
stop: () => void;
};
export type MatrixThreadBindingManagerCacheEntry = {
filePath: string;
manager: MatrixThreadBindingManager;
};
const MANAGERS_BY_ACCOUNT_ID = new Map<string, MatrixThreadBindingManagerCacheEntry>();
const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map<string, MatrixThreadBindingRecord>();
export function resolveBindingKey(params: {
accountId: string;
conversationId: string;
parentConversationId?: string;
}): string {
return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`;
}
function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
export function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind {
return raw === "subagent" ? "subagent" : "acp";
}
export function resolveEffectiveBindingExpiry(params: {
record: MatrixThreadBindingRecord;
defaultIdleTimeoutMs: number;
defaultMaxAgeMs: number;
}): {
expiresAt?: number;
reason?: "idle-expired" | "max-age-expired";
} {
const idleTimeoutMs =
typeof params.record.idleTimeoutMs === "number"
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
: params.defaultIdleTimeoutMs;
const maxAgeMs =
typeof params.record.maxAgeMs === "number"
? Math.max(0, Math.floor(params.record.maxAgeMs))
: params.defaultMaxAgeMs;
const inactivityExpiresAt =
idleTimeoutMs > 0
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
: undefined;
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
return inactivityExpiresAt <= maxAgeExpiresAt
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
}
if (inactivityExpiresAt != null) {
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
}
if (maxAgeExpiresAt != null) {
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
}
return {};
}
export function toSessionBindingRecord(
record: MatrixThreadBindingRecord,
defaults: { idleTimeoutMs: number; maxAgeMs: number },
): SessionBindingRecord {
const lifecycle = resolveEffectiveBindingExpiry({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
defaultMaxAgeMs: defaults.maxAgeMs,
});
const idleTimeoutMs =
typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs;
const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs;
return {
bindingId: resolveBindingKey(record),
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "matrix",
accountId: record.accountId,
conversationId: record.conversationId,
parentConversationId: record.parentConversationId,
},
status: "active",
boundAt: record.boundAt,
expiresAt: lifecycle.expiresAt,
metadata: {
agentId: record.agentId,
label: record.label,
boundBy: record.boundBy,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs,
maxAgeMs,
},
};
}
export function setBindingRecord(record: MatrixThreadBindingRecord): void {
BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record);
}
export function removeBindingRecord(
record: MatrixThreadBindingRecord,
): MatrixThreadBindingRecord | null {
const key = resolveBindingKey(record);
const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null;
if (removed) {
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
}
return removed;
}
export function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] {
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === accountId,
);
}
export function getMatrixThreadBindingManagerEntry(
accountId: string,
): MatrixThreadBindingManagerCacheEntry | null {
return MANAGERS_BY_ACCOUNT_ID.get(accountId) ?? null;
}
export function setMatrixThreadBindingManagerEntry(
accountId: string,
entry: MatrixThreadBindingManagerCacheEntry,
): void {
MANAGERS_BY_ACCOUNT_ID.set(accountId, entry);
}
export function deleteMatrixThreadBindingManagerEntry(accountId: string): void {
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
}
export function getMatrixThreadBindingManager(
accountId: string,
): MatrixThreadBindingManager | null {
return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null;
}
export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: {
accountId: string;
targetSessionKey: string;
idleTimeoutMs: number;
}): SessionBindingRecord[] {
const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager;
if (!manager) {
return [];
}
return manager.setIdleTimeoutBySessionKey(params).map((record) =>
toSessionBindingRecord(record, {
idleTimeoutMs: manager.getIdleTimeoutMs(),
maxAgeMs: manager.getMaxAgeMs(),
}),
);
}
export function setMatrixThreadBindingMaxAgeBySessionKey(params: {
accountId: string;
targetSessionKey: string;
maxAgeMs: number;
}): SessionBindingRecord[] {
const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager;
if (!manager) {
return [];
}
return manager.setMaxAgeBySessionKey(params).map((record) =>
toSessionBindingRecord(record, {
idleTimeoutMs: manager.getIdleTimeoutMs(),
maxAgeMs: manager.getMaxAgeMs(),
}),
);
}
export function resetMatrixThreadBindingsForTests(): void {
for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
}

View File

@@ -6,70 +6,39 @@ import {
resolveThreadBindingFarewellText,
unregisterSessionBindingAdapter,
writeJsonFileAtomically,
type BindingTargetKind,
type SessionBindingRecord,
} from "../runtime-api.js";
import { resolveMatrixStoragePaths } from "./client/storage.js";
import type { MatrixAuth } from "./client/types.js";
import type { MatrixClient } from "./sdk.js";
import { sendMessageMatrix } from "./send.js";
import {
deleteMatrixThreadBindingManagerEntry,
getMatrixThreadBindingManager,
getMatrixThreadBindingManagerEntry,
listBindingsForAccount,
removeBindingRecord,
resetMatrixThreadBindingsForTests,
resolveBindingKey,
resolveEffectiveBindingExpiry,
setBindingRecord,
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingManagerEntry,
setMatrixThreadBindingMaxAgeBySessionKey,
toMatrixBindingTargetKind,
toSessionBindingRecord,
type MatrixThreadBindingManager,
type MatrixThreadBindingRecord,
} from "./thread-bindings-shared.js";
const STORE_VERSION = 1;
const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000;
const TOUCH_PERSIST_DELAY_MS = 30_000;
type MatrixThreadBindingTargetKind = "subagent" | "acp";
type MatrixThreadBindingRecord = {
accountId: string;
conversationId: string;
parentConversationId?: string;
targetKind: MatrixThreadBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
type StoredMatrixThreadBindingState = {
version: number;
bindings: MatrixThreadBindingRecord[];
};
export type MatrixThreadBindingManager = {
accountId: string;
getIdleTimeoutMs: () => number;
getMaxAgeMs: () => number;
getByConversation: (params: {
conversationId: string;
parentConversationId?: string;
}) => MatrixThreadBindingRecord | undefined;
listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[];
listBindings: () => MatrixThreadBindingRecord[];
touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null;
setIdleTimeoutBySessionKey: (params: {
targetSessionKey: string;
idleTimeoutMs: number;
}) => MatrixThreadBindingRecord[];
setMaxAgeBySessionKey: (params: {
targetSessionKey: string;
maxAgeMs: number;
}) => MatrixThreadBindingRecord[];
stop: () => void;
};
type MatrixThreadBindingManagerCacheEntry = {
filePath: string;
manager: MatrixThreadBindingManager;
};
const MANAGERS_BY_ACCOUNT_ID = new Map<string, MatrixThreadBindingManagerCacheEntry>();
const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map<string, MatrixThreadBindingRecord>();
function normalizeDurationMs(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return fallback;
@@ -86,94 +55,6 @@ function normalizeConversationId(raw: unknown): string | undefined {
return trimmed || undefined;
}
function resolveBindingKey(params: {
accountId: string;
conversationId: string;
parentConversationId?: string;
}): string {
return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`;
}
function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind {
return raw === "subagent" ? "subagent" : "acp";
}
function resolveEffectiveBindingExpiry(params: {
record: MatrixThreadBindingRecord;
defaultIdleTimeoutMs: number;
defaultMaxAgeMs: number;
}): {
expiresAt?: number;
reason?: "idle-expired" | "max-age-expired";
} {
const idleTimeoutMs =
typeof params.record.idleTimeoutMs === "number"
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
: params.defaultIdleTimeoutMs;
const maxAgeMs =
typeof params.record.maxAgeMs === "number"
? Math.max(0, Math.floor(params.record.maxAgeMs))
: params.defaultMaxAgeMs;
const inactivityExpiresAt =
idleTimeoutMs > 0
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
: undefined;
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
return inactivityExpiresAt <= maxAgeExpiresAt
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
}
if (inactivityExpiresAt != null) {
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
}
if (maxAgeExpiresAt != null) {
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
}
return {};
}
function toSessionBindingRecord(
record: MatrixThreadBindingRecord,
defaults: { idleTimeoutMs: number; maxAgeMs: number },
): SessionBindingRecord {
const lifecycle = resolveEffectiveBindingExpiry({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
defaultMaxAgeMs: defaults.maxAgeMs,
});
const idleTimeoutMs =
typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs;
const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs;
return {
bindingId: resolveBindingKey(record),
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "matrix",
accountId: record.accountId,
conversationId: record.conversationId,
parentConversationId: record.parentConversationId,
},
status: "active",
boundAt: record.boundAt,
expiresAt: lifecycle.expiresAt,
metadata: {
agentId: record.agentId,
label: record.label,
boundBy: record.boundBy,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs,
maxAgeMs,
},
};
}
function resolveBindingsPath(params: {
auth: MatrixAuth;
accountId: string;
@@ -256,25 +137,6 @@ async function persistBindingsSnapshot(
await writeJsonFileAtomically(filePath, toStoredBindingsState(bindings));
}
function setBindingRecord(record: MatrixThreadBindingRecord): void {
BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record);
}
function removeBindingRecord(record: MatrixThreadBindingRecord): MatrixThreadBindingRecord | null {
const key = resolveBindingKey(record);
const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null;
if (removed) {
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
}
return removed;
}
function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] {
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === accountId,
);
}
function buildMatrixBindingIntroText(params: {
metadata?: Record<string, unknown>;
targetSessionKey: string;
@@ -365,7 +227,7 @@ export async function createMatrixThreadBindingManager(params: {
env: params.env,
stateDir: params.stateDir,
});
const existingEntry = MANAGERS_BY_ACCOUNT_ID.get(params.accountId);
const existingEntry = getMatrixThreadBindingManagerEntry(params.accountId);
if (existingEntry) {
if (existingEntry.filePath === filePath) {
return existingEntry.manager;
@@ -506,11 +368,11 @@ export async function createMatrixThreadBindingManager(params: {
channel: "matrix",
accountId: params.accountId,
});
if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager === manager) {
MANAGERS_BY_ACCOUNT_ID.delete(params.accountId);
if (getMatrixThreadBindingManagerEntry(params.accountId)?.manager === manager) {
deleteMatrixThreadBindingManagerEntry(params.accountId);
}
for (const record of listBindingsForAccount(params.accountId)) {
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(resolveBindingKey(record));
removeBindingRecord(record);
}
},
};
@@ -705,57 +567,15 @@ export async function createMatrixThreadBindingManager(params: {
sweepTimer.unref?.();
}
MANAGERS_BY_ACCOUNT_ID.set(params.accountId, {
setMatrixThreadBindingManagerEntry(params.accountId, {
filePath,
manager,
});
return manager;
}
export function getMatrixThreadBindingManager(
accountId: string,
): MatrixThreadBindingManager | null {
return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null;
}
export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: {
accountId: string;
targetSessionKey: string;
idleTimeoutMs: number;
}): SessionBindingRecord[] {
const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager;
if (!manager) {
return [];
}
return manager.setIdleTimeoutBySessionKey(params).map((record) =>
toSessionBindingRecord(record, {
idleTimeoutMs: manager.getIdleTimeoutMs(),
maxAgeMs: manager.getMaxAgeMs(),
}),
);
}
export function setMatrixThreadBindingMaxAgeBySessionKey(params: {
accountId: string;
targetSessionKey: string;
maxAgeMs: number;
}): SessionBindingRecord[] {
const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager;
if (!manager) {
return [];
}
return manager.setMaxAgeBySessionKey(params).map((record) =>
toSessionBindingRecord(record, {
idleTimeoutMs: manager.getIdleTimeoutMs(),
maxAgeMs: manager.getMaxAgeMs(),
}),
);
}
export function resetMatrixThreadBindingsForTests(): void {
for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
}
export {
getMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
};

View File

@@ -1 +1,2 @@
export * from "openclaw/plugin-sdk/matrix";
export * from "../runtime-api.js";

View File

@@ -0,0 +1,4 @@
export {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./src/matrix/thread-bindings-shared.js";

View File

@@ -85,7 +85,7 @@ export {
export {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "../../extensions/matrix/src/matrix/thread-bindings.js";
} from "../../extensions/matrix/thread-bindings-runtime.js";
export { createTypingCallbacks } from "../channels/typing.js";
export { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
export type { OpenClawConfig } from "../config/config.js";

View File

@@ -195,8 +195,8 @@ export type PluginRuntimeChannel = {
};
matrix: {
threadBindings: {
setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey;
setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey;
setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingIdleTimeoutBySessionKey;
setMaxAgeBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingMaxAgeBySessionKey;
};
};
signal: {