mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 01:46:53 +00:00
Matrix: replace legacy plugin with new implementation
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
|
||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "discord" || normalized === "matrix-js" || normalized === "telegram") {
|
||||
if (normalized === "discord" || normalized === "matrix" || normalized === "telegram") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
@@ -146,7 +146,7 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
if (!targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (channel === "discord" || channel === "matrix-js") {
|
||||
if (channel === "discord" || channel === "matrix") {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
@@ -205,7 +205,7 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channel === "discord" || channel === "matrix-js") {
|
||||
if (channel === "discord" || channel === "matrix") {
|
||||
const bindings = listAcpBindings(params.cfg);
|
||||
const resolveChannelBindingForConversation = (
|
||||
targetConversationId: string,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "matrix-js" | "telegram";
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "matrix" | "telegram";
|
||||
|
||||
export type ConfiguredAcpBindingSpec = {
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
|
||||
@@ -25,7 +25,7 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
|
||||
}
|
||||
|
||||
export function isMatrixSurface(params: DiscordSurfaceParams): boolean {
|
||||
return resolveCommandSurfaceChannel(params) === "matrix-js";
|
||||
return resolveCommandSurfaceChannel(params) === "matrix";
|
||||
}
|
||||
|
||||
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
|
||||
|
||||
@@ -63,6 +63,8 @@ function createAcpCommandSessionBindingService() {
|
||||
listBySession: (targetSessionKey: string) =>
|
||||
hoisted.sessionBindingListBySessionMock(targetSessionKey),
|
||||
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
|
||||
setIdleTimeoutBySession: vi.fn(async () => []),
|
||||
setMaxAgeBySession: vi.fn(async () => []),
|
||||
touch: vi.fn(),
|
||||
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
|
||||
};
|
||||
@@ -118,7 +120,7 @@ type FakeBinding = {
|
||||
targetSessionKey: string;
|
||||
targetKind: "subagent" | "session";
|
||||
conversation: {
|
||||
channel: "discord" | "telegram";
|
||||
channel: "discord" | "matrix" | "telegram";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@@ -243,7 +245,7 @@ function createSessionBindingCapabilities() {
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: {
|
||||
channel?: "discord" | "matrix-js" | "telegram";
|
||||
channel?: "discord" | "matrix" | "telegram";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@@ -267,9 +269,9 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
}
|
||||
: channel === "matrix-js"
|
||||
: channel === "matrix"
|
||||
? {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: input.conversation.parentConversationId ?? "!room:example",
|
||||
@@ -344,9 +346,9 @@ function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseC
|
||||
|
||||
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix-js",
|
||||
Surface: "matrix-js",
|
||||
OriginatingChannel: "matrix-js",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example",
|
||||
AccountId: "default",
|
||||
});
|
||||
@@ -651,9 +653,7 @@ describe("/acp command", () => {
|
||||
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is not enabled", async () => {
|
||||
const result = await runMatrixRoomAcpCommand("/acp spawn codex --thread auto");
|
||||
|
||||
expect(result?.reply?.text).toContain(
|
||||
"channels.matrix-js.threadBindings.spawnAcpSessions=true",
|
||||
);
|
||||
expect(result?.reply?.text).toContain("channels.matrix.threadBindings.spawnAcpSessions=true");
|
||||
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -663,7 +663,7 @@ describe("/acp command", () => {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
"matrix-js": {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
@@ -680,7 +680,7 @@ describe("/acp command", () => {
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-42",
|
||||
parentConversationId: "!room:example",
|
||||
|
||||
@@ -129,16 +129,16 @@ describe("commands-acp context", () => {
|
||||
|
||||
it("resolves Matrix thread conversation ids from room targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "matrix-js",
|
||||
Surface: "matrix-js",
|
||||
OriginatingChannel: "matrix-js",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example",
|
||||
MessageThreadId: "$thread-42",
|
||||
AccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
accountId: "work",
|
||||
threadId: "$thread-42",
|
||||
conversationId: "$thread-42",
|
||||
|
||||
@@ -44,7 +44,7 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
|
||||
|
||||
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "matrix-js") {
|
||||
if (channel === "matrix") {
|
||||
return resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
@@ -112,7 +112,7 @@ export function resolveAcpCommandParentConversationId(
|
||||
params: HandleCommandsParams,
|
||||
): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "matrix-js") {
|
||||
if (channel === "matrix") {
|
||||
return resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
|
||||
@@ -142,9 +142,9 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
|
||||
|
||||
function createMatrixCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix-js",
|
||||
Surface: "matrix-js",
|
||||
OriginatingChannel: "matrix-js",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example",
|
||||
To: "room:!room:example",
|
||||
AccountId: "default",
|
||||
@@ -203,7 +203,7 @@ function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): Session
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example",
|
||||
@@ -241,8 +241,10 @@ describe("/session idle and /session max-age", () => {
|
||||
{
|
||||
targetSessionKey: binding.targetSessionKey,
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -287,7 +289,9 @@ describe("/session idle and /session max-age", () => {
|
||||
{
|
||||
targetSessionKey: binding.targetSessionKey,
|
||||
boundAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -315,8 +319,10 @@ describe("/session idle and /session max-age", () => {
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -347,8 +353,10 @@ describe("/session idle and /session max-age", () => {
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt,
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -376,8 +384,10 @@ describe("/session idle and /session max-age", () => {
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
|
||||
const accountId = resolveChannelAccountId(params);
|
||||
const sessionBindingService = getSessionBindingService();
|
||||
const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix-js";
|
||||
const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix";
|
||||
const threadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
const conversationId = onTelegram
|
||||
|
||||
@@ -107,9 +107,9 @@ function createTelegramTopicCommandParams(commandBody: string) {
|
||||
|
||||
function createMatrixCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix-js",
|
||||
Surface: "matrix-js",
|
||||
OriginatingChannel: "matrix-js",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example",
|
||||
To: "room:!room:example",
|
||||
AccountId: "default",
|
||||
@@ -239,7 +239,7 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
@@ -253,7 +253,7 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
expect.objectContaining({
|
||||
placement: "child",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
conversationId: "!room:example",
|
||||
parentConversationId: "!room:example",
|
||||
}),
|
||||
@@ -264,9 +264,7 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
it("/focus rejects Matrix child thread creation when spawn config is not enabled", async () => {
|
||||
const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp"));
|
||||
|
||||
expect(result?.reply?.text).toContain(
|
||||
"channels.matrix-js.threadBindings.spawnAcpSessions=true",
|
||||
);
|
||||
expect(result?.reply?.text).toContain("channels.matrix.threadBindings.spawnAcpSessions=true");
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function formatConversationBindingText(params: {
|
||||
if (params.channel === "discord") {
|
||||
return `thread:${params.conversationId}`;
|
||||
}
|
||||
if (params.channel === "matrix-js") {
|
||||
if (params.channel === "matrix") {
|
||||
return `thread:${params.conversationId}`;
|
||||
}
|
||||
if (params.channel === "telegram") {
|
||||
@@ -67,9 +67,9 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma
|
||||
channel,
|
||||
conversationId: binding.conversation.conversationId,
|
||||
})
|
||||
: channel === "discord" || channel === "telegram" || channel === "matrix-js"
|
||||
: channel === "discord" || channel === "telegram" || channel === "matrix"
|
||||
? "unbound"
|
||||
: "bindings available on discord/matrix-js/telegram";
|
||||
: "bindings available on discord/matrix/telegram";
|
||||
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./shared.js";
|
||||
|
||||
type FocusBindingContext = {
|
||||
channel: "discord" | "telegram" | "matrix-js";
|
||||
channel: "discord" | "telegram" | "matrix";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@@ -98,7 +98,7 @@ function resolveFocusBindingContext(
|
||||
},
|
||||
});
|
||||
return {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
accountId: resolveChannelAccountId(params),
|
||||
conversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
@@ -114,7 +114,7 @@ export async function handleSubagentsFocusAction(
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
const channel = resolveCommandSurfaceChannel(params);
|
||||
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix-js") {
|
||||
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix") {
|
||||
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function handleSubagentsFocusAction(
|
||||
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
|
||||
);
|
||||
}
|
||||
if (channel === "matrix-js") {
|
||||
if (channel === "matrix") {
|
||||
return stopWithText("⚠️ Could not resolve a Matrix conversation for /focus.");
|
||||
}
|
||||
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
|
||||
@@ -185,7 +185,7 @@ export async function handleSubagentsFocusAction(
|
||||
if (!capabilities.placements.includes(bindingContext.placement)) {
|
||||
return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`);
|
||||
}
|
||||
if (bindingContext.channel === "matrix-js" && bindingContext.placement === "child") {
|
||||
if (bindingContext.channel === "matrix" && bindingContext.placement === "child") {
|
||||
const spawnPolicy = resolveThreadBindingSpawnPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: bindingContext.channel,
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function handleSubagentsUnfocusAction(
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params } = ctx;
|
||||
const channel = resolveCommandSurfaceChannel(params);
|
||||
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix-js") {
|
||||
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix") {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function handleSubagentsUnfocusAction(
|
||||
if (channel === "discord") {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
|
||||
}
|
||||
if (channel === "matrix-js") {
|
||||
if (channel === "matrix") {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a focused Matrix thread.");
|
||||
}
|
||||
return stopWithText(
|
||||
@@ -82,7 +82,7 @@ export async function handleSubagentsUnfocusAction(
|
||||
return stopWithText(
|
||||
channel === "discord"
|
||||
? "ℹ️ This thread is not currently focused."
|
||||
: channel === "matrix-js"
|
||||
: channel === "matrix"
|
||||
? "ℹ️ This thread is not currently focused."
|
||||
: "ℹ️ This conversation is not currently focused.",
|
||||
);
|
||||
@@ -95,7 +95,7 @@ export async function handleSubagentsUnfocusAction(
|
||||
return stopWithText(
|
||||
channel === "discord"
|
||||
? `⚠️ Only ${boundBy} can unfocus this thread.`
|
||||
: channel === "matrix-js"
|
||||
: channel === "matrix"
|
||||
? `⚠️ Only ${boundBy} can unfocus this thread.`
|
||||
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export async function handleSubagentsUnfocusAction(
|
||||
reason: "manual",
|
||||
});
|
||||
return stopWithText(
|
||||
channel === "discord" || channel === "matrix-js"
|
||||
channel === "discord" || channel === "matrix"
|
||||
? "✅ Thread unfocused."
|
||||
: "✅ Conversation unfocused.",
|
||||
);
|
||||
|
||||
@@ -235,6 +235,29 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
|
||||
]);
|
||||
|
||||
const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record<string, ReadonlySet<string>> = {
|
||||
matrix: new Set([
|
||||
"initialSyncLimit",
|
||||
"encryption",
|
||||
"allowlistOnly",
|
||||
"replyToMode",
|
||||
"threadReplies",
|
||||
"textChunkLimit",
|
||||
"chunkMode",
|
||||
"responsePrefix",
|
||||
"ackReaction",
|
||||
"ackReactionScope",
|
||||
"reactionNotifications",
|
||||
"threadBindings",
|
||||
"startupVerification",
|
||||
"startupVerificationCooldownHours",
|
||||
"mediaMaxMb",
|
||||
"autoJoin",
|
||||
"autoJoinAllowlist",
|
||||
"dm",
|
||||
"groups",
|
||||
"rooms",
|
||||
"actions",
|
||||
]),
|
||||
telegram: new Set(["streaming"]),
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("resolveThreadBindingSpawnPolicy", () => {
|
||||
expect(
|
||||
resolveThreadBindingSpawnPolicy({
|
||||
cfg: baseCfg,
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
kind: "subagent",
|
||||
}).spawnEnabled,
|
||||
).toBe(false);
|
||||
@@ -35,7 +35,7 @@ describe("resolveThreadBindingSpawnPolicy", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: true,
|
||||
spawnAcpSessions: true,
|
||||
@@ -47,14 +47,14 @@ describe("resolveThreadBindingSpawnPolicy", () => {
|
||||
expect(
|
||||
resolveThreadBindingSpawnPolicy({
|
||||
cfg,
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
kind: "subagent",
|
||||
}).spawnEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveThreadBindingSpawnPolicy({
|
||||
cfg,
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
kind: "acp",
|
||||
}).spawnEnabled,
|
||||
).toBe(true);
|
||||
|
||||
@@ -2,7 +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_JS_THREAD_BINDING_CHANNEL = "matrix-js";
|
||||
export const MATRIX_THREAD_BINDING_CHANNEL = "matrix";
|
||||
export const TELEGRAM_THREAD_BINDING_CHANNEL = "telegram";
|
||||
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
|
||||
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
|
||||
@@ -116,7 +116,7 @@ function resolveSpawnConfigPath(params: {
|
||||
params.kind === "subagent" ? "spawnSubagentSessions=true" : "spawnAcpSessions=true";
|
||||
if (
|
||||
params.channel === DISCORD_THREAD_BINDING_CHANNEL ||
|
||||
params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL ||
|
||||
params.channel === MATRIX_THREAD_BINDING_CHANNEL ||
|
||||
params.channel === TELEGRAM_THREAD_BINDING_CHANNEL
|
||||
) {
|
||||
return `channels.${params.channel}.threadBindings.${suffix}`;
|
||||
@@ -215,8 +215,8 @@ export function formatThreadBindingSpawnDisabledError(params: {
|
||||
const label =
|
||||
params.channel === DISCORD_THREAD_BINDING_CHANNEL
|
||||
? "Discord"
|
||||
: params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL
|
||||
? "Matrix-js"
|
||||
: params.channel === MATRIX_THREAD_BINDING_CHANNEL
|
||||
? "Matrix"
|
||||
: params.channel === TELEGRAM_THREAD_BINDING_CHANNEL
|
||||
? "Telegram"
|
||||
: 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,25 @@ 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 +125,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 +135,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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js";
|
||||
import { matrixPlugin } from "../../extensions/matrix/src/channel.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { agentsBindCommand } from "./agents.js";
|
||||
@@ -15,7 +15,7 @@ vi.mock("../config/config.js", async (importOriginal) => ({
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
}));
|
||||
|
||||
describe("agents bind matrix-js integration", () => {
|
||||
describe("agents bind matrix integration", () => {
|
||||
const runtime = createTestRuntime();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -26,7 +26,7 @@ describe("agents bind matrix-js integration", () => {
|
||||
runtime.exit.mockClear();
|
||||
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]),
|
||||
createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,17 +34,19 @@ describe("agents bind matrix-js integration", () => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
it("uses matrix-js plugin binding resolver when accountId is omitted", async () => {
|
||||
it("uses matrix plugin binding resolver when accountId is 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();
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../infra/matrix-storage-paths.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
@@ -145,6 +146,185 @@ describe("doctor config flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("previews Matrix legacy 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");
|
||||
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("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("Matrix is installed from a custom path that no longer exists"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
doctorWarnings.some((line) => line.includes("openclaw plugins install @openclaw/matrix")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
|
||||
@@ -26,6 +26,14 @@ 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 { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import {
|
||||
formatChannelAccountsDefaultPath,
|
||||
@@ -285,6 +293,68 @@ function collectTelegramAllowFromLists(
|
||||
return refs;
|
||||
}
|
||||
|
||||
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}`,
|
||||
'- 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 install = cfg.plugins?.installs?.matrix;
|
||||
if (!install || install.source !== "path") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidatePaths = [install.sourcePath, install.installPath]
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean);
|
||||
if (candidatePaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
try {
|
||||
await fs.access(path.resolve(candidatePath));
|
||||
return [];
|
||||
} catch {
|
||||
// keep checking remaining candidates
|
||||
}
|
||||
}
|
||||
|
||||
const missingPath = candidatePaths[0] ?? "(unknown)";
|
||||
return [
|
||||
`- Matrix is installed from a custom path that no longer exists: ${missingPath}`,
|
||||
`- Reinstall with "${formatCliCommand("openclaw plugins install @openclaw/matrix")}".`,
|
||||
`- If you are running from a repo checkout, you can also use "${formatCliCommand("openclaw plugins install ./extensions/matrix")}".`,
|
||||
];
|
||||
}
|
||||
|
||||
function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] {
|
||||
const hits: TelegramAllowFromUsernameHit[] = [];
|
||||
|
||||
@@ -1733,6 +1803,69 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const matrixLegacyState = detectLegacyMatrixState({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const matrixLegacyCrypto = detectLegacyMatrixCrypto({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (shouldRepair) {
|
||||
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) {
|
||||
|
||||
@@ -96,6 +96,25 @@ describe("legacy migrate mention routing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate Matrix config", () => {
|
||||
it("removes the obsolete channels.matrix.register toggle", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
channels: {
|
||||
matrix: {
|
||||
register: false,
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain("Removed obsolete channels.matrix.register.");
|
||||
expect(
|
||||
(res.config?.channels?.matrix as { register?: unknown } | undefined)?.register,
|
||||
).toBeUndefined();
|
||||
expect(res.config?.channels?.matrix?.homeserver).toBe("https://matrix.example.org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate heartbeat config", () => {
|
||||
it("moves top-level heartbeat into agents.defaults.heartbeat", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
|
||||
@@ -97,6 +97,19 @@ function mergeLegacyIntoDefaults(params: {
|
||||
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
|
||||
|
||||
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
{
|
||||
id: "channels.matrix.register-remove",
|
||||
describe: "Remove obsolete Matrix registration toggle",
|
||||
apply: (raw, changes) => {
|
||||
const channels = getRecord(raw.channels);
|
||||
const matrix = getRecord(channels?.matrix);
|
||||
if (!matrix || !("register" in matrix)) {
|
||||
return;
|
||||
}
|
||||
delete matrix.register;
|
||||
changes.push("Removed obsolete channels.matrix.register.");
|
||||
},
|
||||
},
|
||||
{
|
||||
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
|
||||
// host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated
|
||||
|
||||
@@ -459,7 +459,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"bindings[].match":
|
||||
"Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.",
|
||||
"bindings[].match.channel":
|
||||
"Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix-js`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.",
|
||||
"Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.",
|
||||
"bindings[].match.accountId":
|
||||
"Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.",
|
||||
"bindings[].match.peer":
|
||||
@@ -1553,15 +1553,15 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
|
||||
"channels.discord.threadBindings.spawnAcpSessions":
|
||||
"Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
|
||||
"channels.matrix-js.threadBindings.enabled":
|
||||
"Enable Matrix-js thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.",
|
||||
"channels.matrix-js.threadBindings.idleHours":
|
||||
"Inactivity window in hours for Matrix-js thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
|
||||
"channels.matrix-js.threadBindings.maxAgeHours":
|
||||
"Optional hard max age in hours for Matrix-js thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
|
||||
"channels.matrix-js.threadBindings.spawnSubagentSessions":
|
||||
"channels.matrix.threadBindings.enabled":
|
||||
"Enable Matrix thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.",
|
||||
"channels.matrix.threadBindings.idleHours":
|
||||
"Inactivity window in hours for Matrix thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
|
||||
"channels.matrix.threadBindings.maxAgeHours":
|
||||
"Optional hard max age in hours for Matrix thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
|
||||
"channels.matrix.threadBindings.spawnSubagentSessions":
|
||||
"Allow top-level /focus flows to auto-create and bind Matrix threads for subagent/session targets (default: false; opt-in). Set true to enable Matrix thread creation/binding from room or DM contexts.",
|
||||
"channels.matrix-js.threadBindings.spawnAcpSessions":
|
||||
"channels.matrix.threadBindings.spawnAcpSessions":
|
||||
"Allow /acp spawn to create or bind Matrix threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
|
||||
"channels.discord.ui.components.accentColor":
|
||||
"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",
|
||||
|
||||
@@ -776,12 +776,11 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.threadBindings.maxAgeHours": "Discord Thread Binding Max Age (hours)",
|
||||
"channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn",
|
||||
"channels.discord.threadBindings.spawnAcpSessions": "Discord Thread-Bound ACP Spawn",
|
||||
"channels.matrix-js.threadBindings.enabled": "Matrix-js Thread Binding Enabled",
|
||||
"channels.matrix-js.threadBindings.idleHours": "Matrix-js Thread Binding Idle Timeout (hours)",
|
||||
"channels.matrix-js.threadBindings.maxAgeHours": "Matrix-js Thread Binding Max Age (hours)",
|
||||
"channels.matrix-js.threadBindings.spawnSubagentSessions":
|
||||
"Matrix-js Thread-Bound Subagent Spawn",
|
||||
"channels.matrix-js.threadBindings.spawnAcpSessions": "Matrix-js Thread-Bound ACP Spawn",
|
||||
"channels.matrix.threadBindings.enabled": "Matrix Thread Binding Enabled",
|
||||
"channels.matrix.threadBindings.idleHours": "Matrix Thread Binding Idle Timeout (hours)",
|
||||
"channels.matrix.threadBindings.maxAgeHours": "Matrix Thread Binding Max Age (hours)",
|
||||
"channels.matrix.threadBindings.spawnSubagentSessions": "Matrix Thread-Bound Subagent Spawn",
|
||||
"channels.matrix.threadBindings.spawnAcpSessions": "Matrix Thread-Bound ACP Spawn",
|
||||
"channels.discord.ui.components.accentColor": "Discord Component Accent Color",
|
||||
"channels.discord.intents.presence": "Discord Presence Intent",
|
||||
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
|
||||
|
||||
@@ -100,6 +100,16 @@ export type SessionThreadBindingsConfig = {
|
||||
* Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0.
|
||||
*/
|
||||
maxAgeHours?: number;
|
||||
/**
|
||||
* Allow thread-capable channels to create and bind child conversations for subagent sessions.
|
||||
* Channels that support this use explicit opt-in. Default: false.
|
||||
*/
|
||||
spawnSubagentSessions?: boolean;
|
||||
/**
|
||||
* Allow thread-capable channels to create and bind child conversations for ACP sessions.
|
||||
* Channels that support this use explicit opt-in. Default: false.
|
||||
*/
|
||||
spawnAcpSessions?: boolean;
|
||||
};
|
||||
|
||||
export type SessionConfig = {
|
||||
|
||||
@@ -71,12 +71,12 @@ const AcpBindingSchema = z
|
||||
return;
|
||||
}
|
||||
const channel = value.match.channel.trim().toLowerCase();
|
||||
if (channel !== "discord" && channel !== "matrix-js" && channel !== "telegram") {
|
||||
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match", "channel"],
|
||||
message:
|
||||
'ACP bindings currently support only "discord", "matrix-js", and "telegram" channels.',
|
||||
'ACP bindings currently support only "discord", "matrix", and "telegram" channels.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js
|
||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js";
|
||||
import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
|
||||
import {
|
||||
@@ -331,6 +333,16 @@ export async function startGatewayServer(
|
||||
}
|
||||
|
||||
let secretsDegraded = false;
|
||||
await autoMigrateLegacyMatrixState({
|
||||
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
|
||||
env: process.env,
|
||||
log,
|
||||
});
|
||||
await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
|
||||
env: process.env,
|
||||
log,
|
||||
});
|
||||
const emitSecretsStateEvent = (
|
||||
code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED",
|
||||
message: string,
|
||||
|
||||
122
src/infra/matrix-legacy-crypto.test.ts
Normal file
122
src/infra/matrix-legacy-crypto.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf8");
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const inspectLegacyStore = vi.fn(async () => ({
|
||||
deviceId: "DEVICE123",
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}));
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: { inspectLegacyStore },
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(inspectLegacyStore).toHaveBeenCalledOnce();
|
||||
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
restoreStatus: string;
|
||||
decryptionKeyImported: boolean;
|
||||
};
|
||||
expect(state.restoreStatus).toBe("pending");
|
||||
expect(state.decryptionKeyImported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: "DEVICE123",
|
||||
roomKeyCounts: { total: 15, backedUp: 10 },
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
|
||||
);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
restoreStatus: string;
|
||||
};
|
||||
expect(state.restoreStatus).toBe("manual-action-required");
|
||||
});
|
||||
});
|
||||
});
|
||||
576
src/infra/matrix-legacy-crypto.ts
Normal file
576
src/infra/matrix-legacy-crypto.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "./matrix-storage-paths.js";
|
||||
|
||||
type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoSummary = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: MatrixLegacyCryptoCounts | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoMigrationState = {
|
||||
version: 1;
|
||||
source: "matrix-bot-sdk-rust";
|
||||
accountId: string;
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: MatrixLegacyCryptoCounts | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyImported: boolean;
|
||||
restoreStatus: "pending" | "completed" | "manual-action-required";
|
||||
detectedAt: string;
|
||||
restoredAt?: string;
|
||||
importedCount?: number;
|
||||
totalCount?: number;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPlan = {
|
||||
accountId: string;
|
||||
rootDir: string;
|
||||
recoveryKeyPath: string;
|
||||
statePath: string;
|
||||
legacyCryptoPath: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoDetection = {
|
||||
plans: MatrixLegacyCryptoPlan[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoPreparationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoPrepareDeps = {
|
||||
inspectLegacyStore: (params: {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}) => Promise<MatrixLegacyCryptoSummary>;
|
||||
};
|
||||
|
||||
type MatrixLegacyBotSdkMetadata = {
|
||||
deviceId: string | null;
|
||||
};
|
||||
|
||||
type MatrixStoredRecoveryKey = {
|
||||
version: 1;
|
||||
createdAt: string;
|
||||
keyId?: string | null;
|
||||
encodedPrivateKey?: string;
|
||||
privateKeyBase64: string;
|
||||
keyInfo?: {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
|
||||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
|
||||
fs
|
||||
.readdirSync(cryptoRootDir, { withFileTypes: true })
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function loadStoredMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId: string,
|
||||
): MatrixStoredCredentials | null {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
const credentialsPath = resolveMatrixCredentialsPath({
|
||||
stateDir,
|
||||
accountId: normalizeAccountId(accountId),
|
||||
});
|
||||
try {
|
||||
if (!fs.existsSync(credentialsPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(credentialsPath, "utf8"),
|
||||
) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
homeserver: parsed.homeserver,
|
||||
userId: parsed.userId,
|
||||
accessToken: parsed.accessToken,
|
||||
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
}
|
||||
|
||||
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
const ids = Object.keys(accounts).map((accountId) => normalizeAccountId(accountId));
|
||||
return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatrixAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Record<string, unknown> {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return {};
|
||||
}
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
|
||||
const merged = {
|
||||
...channel,
|
||||
...accountEntry,
|
||||
};
|
||||
delete merged.accounts;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixFlatStorePlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoPlan | { warning: string } | null {
|
||||
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
|
||||
if (!fs.existsSync(legacy.cryptoPath) || !isLegacyBotSdkCryptoStore(legacy.cryptoPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = resolveMatrixChannelConfig(params.cfg);
|
||||
if (!channel) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but channels.matrix is not configured yet. ` +
|
||||
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
|
||||
};
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
const accountId =
|
||||
configuredDefault && accounts && isRecord(accounts[configuredDefault])
|
||||
? configuredDefault
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
const account = resolveMatrixAccountConfig(params.cfg, accountId);
|
||||
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
|
||||
const userId =
|
||||
(typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || "";
|
||||
const accessToken =
|
||||
(typeof account.accessToken === "string" ? account.accessToken.trim() : "") ||
|
||||
stored?.accessToken ||
|
||||
"";
|
||||
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but the account-scoped target could not be resolved yet ` +
|
||||
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
|
||||
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
|
||||
};
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
|
||||
return {
|
||||
accountId,
|
||||
rootDir,
|
||||
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
|
||||
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
|
||||
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
|
||||
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
|
||||
try {
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
|
||||
deviceId?: unknown;
|
||||
};
|
||||
return {
|
||||
deviceId:
|
||||
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixLegacyCryptoPlans(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoDetection {
|
||||
const warnings: string[] = [];
|
||||
const plans: MatrixLegacyCryptoPlan[] = [];
|
||||
|
||||
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
|
||||
if (flatPlan) {
|
||||
if ("warning" in flatPlan) {
|
||||
warnings.push(flatPlan.warning);
|
||||
} else {
|
||||
plans.push(flatPlan);
|
||||
}
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
|
||||
const account = resolveMatrixAccountConfig(params.cfg, accountId);
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
const homeserver =
|
||||
(typeof account.homeserver === "string" ? account.homeserver.trim() : "") ||
|
||||
stored?.homeserver ||
|
||||
"";
|
||||
const userId =
|
||||
(typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || "";
|
||||
const accessToken =
|
||||
(typeof account.accessToken === "string" ? account.accessToken.trim() : "") ||
|
||||
stored?.accessToken ||
|
||||
"";
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
continue;
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
const legacyCryptoPath = path.join(rootDir, "crypto");
|
||||
if (!fs.existsSync(legacyCryptoPath) || !isLegacyBotSdkCryptoStore(legacyCryptoPath)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
plans.some(
|
||||
(plan) =>
|
||||
plan.accountId === accountId &&
|
||||
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
|
||||
plans.push({
|
||||
accountId,
|
||||
rootDir,
|
||||
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
|
||||
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
|
||||
legacyCryptoPath,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return { plans, warnings };
|
||||
}
|
||||
|
||||
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegacyMachineStorePath(params: {
|
||||
cryptoRootDir: string;
|
||||
deviceId: string;
|
||||
}): string | null {
|
||||
const hashedDir = path.join(
|
||||
params.cryptoRootDir,
|
||||
crypto.createHash("sha256").update(params.deviceId).digest("hex"),
|
||||
);
|
||||
if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return hashedDir;
|
||||
}
|
||||
if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return params.cryptoRootDir;
|
||||
}
|
||||
const match = fs
|
||||
.readdirSync(params.cryptoRootDir, { withFileTypes: true })
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
);
|
||||
return match ? path.join(params.cryptoRootDir, match.name) : null;
|
||||
}
|
||||
|
||||
async function inspectLegacyStoreWithCryptoNodejs(params: {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}): Promise<MatrixLegacyCryptoSummary> {
|
||||
const machineStorePath = resolveLegacyMachineStorePath(params);
|
||||
if (!machineStorePath) {
|
||||
throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`);
|
||||
}
|
||||
const { DeviceId, OlmMachine, StoreType, UserId } =
|
||||
await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
const machine = await OlmMachine.initialize(
|
||||
new UserId(params.userId),
|
||||
new DeviceId(params.deviceId),
|
||||
machineStorePath,
|
||||
"",
|
||||
StoreType.Sqlite,
|
||||
);
|
||||
try {
|
||||
const [backupKeys, roomKeyCounts] = await Promise.all([
|
||||
machine.getBackupKeys(),
|
||||
machine.roomKeyCounts(),
|
||||
]);
|
||||
return {
|
||||
deviceId: params.deviceId,
|
||||
roomKeyCounts: roomKeyCounts
|
||||
? {
|
||||
total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0,
|
||||
backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0,
|
||||
}
|
||||
: null,
|
||||
backupVersion:
|
||||
typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim()
|
||||
? backupKeys.backupVersion
|
||||
: null,
|
||||
decryptionKeyBase64:
|
||||
typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim()
|
||||
? backupKeys.decryptionKeyBase64
|
||||
: null,
|
||||
};
|
||||
} finally {
|
||||
machine.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLegacyMigrationState(params: {
|
||||
filePath: string;
|
||||
state: MatrixLegacyCryptoMigrationState;
|
||||
}): Promise<void> {
|
||||
await writeJsonFileAtomically(params.filePath, params.state);
|
||||
}
|
||||
|
||||
export function detectLegacyMatrixCrypto(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoDetection {
|
||||
return resolveMatrixLegacyCryptoPlans({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
}
|
||||
|
||||
export async function autoPrepareLegacyMatrixCrypto(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
|
||||
}): Promise<MatrixLegacyCryptoPreparationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env });
|
||||
const warnings = [...detection.warnings];
|
||||
const changes: string[] = [];
|
||||
const inspectLegacyStore = params.deps?.inspectLegacyStore ?? inspectLegacyStoreWithCryptoNodejs;
|
||||
|
||||
for (const plan of detection.plans) {
|
||||
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
|
||||
if (existingState?.version === 1) {
|
||||
continue;
|
||||
}
|
||||
if (!plan.deviceId) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
|
||||
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let summary: MatrixLegacyCryptoSummary;
|
||||
try {
|
||||
summary = await inspectLegacyStore({
|
||||
cryptoRootDir: plan.legacyCryptoPath,
|
||||
userId: plan.userId,
|
||||
deviceId: plan.deviceId,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let decryptionKeyImported = false;
|
||||
if (summary.decryptionKeyBase64) {
|
||||
const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath);
|
||||
if (
|
||||
existingRecoveryKey?.privateKeyBase64 &&
|
||||
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
|
||||
) {
|
||||
warnings.push(
|
||||
`Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`,
|
||||
);
|
||||
} else if (!existingRecoveryKey?.privateKeyBase64) {
|
||||
const payload: MatrixStoredRecoveryKey = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: null,
|
||||
privateKeyBase64: summary.decryptionKeyBase64,
|
||||
};
|
||||
await writeJsonFileAtomically(plan.recoveryKeyPath, payload);
|
||||
changes.push(
|
||||
`Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`,
|
||||
);
|
||||
decryptionKeyImported = true;
|
||||
} else {
|
||||
decryptionKeyImported = true;
|
||||
}
|
||||
}
|
||||
|
||||
const localOnlyKeys =
|
||||
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
|
||||
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
|
||||
: 0;
|
||||
if (localOnlyKeys > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
|
||||
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
|
||||
);
|
||||
}
|
||||
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
|
||||
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
|
||||
);
|
||||
}
|
||||
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
|
||||
);
|
||||
}
|
||||
|
||||
const state: MatrixLegacyCryptoMigrationState = {
|
||||
version: 1,
|
||||
source: "matrix-bot-sdk-rust",
|
||||
accountId: plan.accountId,
|
||||
deviceId: summary.deviceId,
|
||||
roomKeyCounts: summary.roomKeyCounts,
|
||||
backupVersion: summary.backupVersion,
|
||||
decryptionKeyImported,
|
||||
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
|
||||
detectedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
};
|
||||
await persistLegacyMigrationState({ filePath: plan.statePath, state });
|
||||
changes.push(
|
||||
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
params.log?.info?.(
|
||||
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
86
src/infra/matrix-legacy-state.test.ts
Normal file
86
src/infra/matrix-legacy-state.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./matrix-legacy-state.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf-8");
|
||||
}
|
||||
|
||||
describe("matrix legacy state migration", () => {
|
||||
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-from-cache",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected cached credentials to make Matrix migration resolvable");
|
||||
}
|
||||
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
286
src/infra/matrix-legacy-state.ts
Normal file
286
src/infra/matrix-legacy-state.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "./matrix-storage-paths.js";
|
||||
|
||||
type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
export type MatrixLegacyStateMigrationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyStatePlan = {
|
||||
accountId: string;
|
||||
legacyStoragePath: string;
|
||||
legacyCryptoPath: string;
|
||||
targetRootDir: string;
|
||||
targetStoragePath: string;
|
||||
targetCryptoPath: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return resolveMatrixLegacyFlatStoragePaths(stateDir);
|
||||
}
|
||||
|
||||
function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv, accountId: string): string {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return resolveSharedMatrixCredentialsPath({
|
||||
stateDir,
|
||||
accountId: normalizeAccountId(accountId),
|
||||
});
|
||||
}
|
||||
|
||||
function loadStoredMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId: string,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credentialsPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (!fs.existsSync(credentialsPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(credentialsPath, "utf-8"),
|
||||
) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
homeserver: parsed.homeserver,
|
||||
userId: parsed.userId,
|
||||
accessToken: parsed.accessToken,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
}
|
||||
|
||||
function resolveMatrixAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Record<string, unknown> {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
|
||||
|
||||
const merged = {
|
||||
...channel,
|
||||
...accountEntry,
|
||||
};
|
||||
delete merged.accounts;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveMatrixMigrationPlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyStatePlan | { warning: string } | null {
|
||||
const legacy = resolveLegacyMatrixPaths(params.env);
|
||||
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = resolveMatrixChannelConfig(params.cfg);
|
||||
if (!channel) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix state detected at ${legacy.rootDir}, but channels.matrix is not configured yet. ` +
|
||||
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveMatrixTargetAccountId(params.cfg);
|
||||
const account = resolveMatrixAccountConfig(params.cfg, accountId);
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
|
||||
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
|
||||
const configUserId = typeof account.userId === "string" ? account.userId.trim() : "";
|
||||
const configAccessToken =
|
||||
typeof account.accessToken === "string" ? account.accessToken.trim() : "";
|
||||
|
||||
const storedMatchesHomeserver =
|
||||
stored && homeserver ? stored.homeserver === homeserver : Boolean(stored);
|
||||
const storedMatchesUser =
|
||||
stored && configUserId ? stored.userId === configUserId : Boolean(stored);
|
||||
|
||||
const userId =
|
||||
configUserId || (storedMatchesHomeserver && storedMatchesUser ? (stored?.userId ?? "") : "");
|
||||
const accessToken =
|
||||
configAccessToken ||
|
||||
(storedMatchesHomeserver && storedMatchesUser ? (stored?.accessToken ?? "") : "");
|
||||
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix state detected at ${legacy.rootDir}, but the new account-scoped target could not be resolved yet ` +
|
||||
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
|
||||
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
|
||||
};
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
accountId,
|
||||
legacyStoragePath: legacy.storagePath,
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
targetRootDir: rootDir,
|
||||
targetStoragePath: path.join(rootDir, "bot-storage.json"),
|
||||
targetCryptoPath: path.join(rootDir, "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
export function detectLegacyMatrixState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyStatePlan | { warning: string } | null {
|
||||
return resolveMatrixMigrationPlan({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function moveLegacyPath(params: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
label: string;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
}): void {
|
||||
if (!fs.existsSync(params.sourcePath)) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(params.targetPath)) {
|
||||
params.warnings.push(
|
||||
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
|
||||
fs.renameSync(params.sourcePath, params.targetPath);
|
||||
params.changes.push(
|
||||
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
|
||||
);
|
||||
} catch (err) {
|
||||
params.warnings.push(
|
||||
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoMigrateLegacyMatrixState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
}): Promise<MatrixLegacyStateMigrationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
|
||||
if (!detection) {
|
||||
return { migrated: false, changes: [], warnings: [] };
|
||||
}
|
||||
if ("warning" in detection) {
|
||||
params.log?.warn?.(`matrix: ${detection.warning}`);
|
||||
return { migrated: false, changes: [], warnings: [detection.warning] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
moveLegacyPath({
|
||||
sourcePath: detection.legacyStoragePath,
|
||||
targetPath: detection.targetStoragePath,
|
||||
label: "sync store",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
moveLegacyPath({
|
||||
sourcePath: detection.legacyCryptoPath,
|
||||
targetPath: detection.targetCryptoPath,
|
||||
label: "crypto store",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
|
||||
if (changes.length > 0) {
|
||||
params.log?.info?.(
|
||||
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}\n- No user action required.`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
93
src/infra/matrix-storage-paths.ts
Normal file
93
src/infra/matrix-storage-paths.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
|
||||
export function sanitizeMatrixPathSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || "unknown";
|
||||
}
|
||||
|
||||
export function resolveMatrixHomeserverKey(homeserver: string): string {
|
||||
try {
|
||||
const url = new URL(homeserver);
|
||||
if (url.host) {
|
||||
return sanitizeMatrixPathSegment(url.host);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return sanitizeMatrixPathSegment(homeserver);
|
||||
}
|
||||
|
||||
export function hashMatrixAccessToken(accessToken: string): string {
|
||||
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsFilename(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(stateDir: string): string {
|
||||
return path.join(stateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(params: {
|
||||
stateDir: string;
|
||||
accountId?: string | null;
|
||||
}): string {
|
||||
return path.join(
|
||||
resolveMatrixCredentialsDir(params.stateDir),
|
||||
resolveMatrixCredentialsFilename(params.accountId),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixLegacyFlatStoreRoot(stateDir: string): string {
|
||||
return path.join(stateDir, "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const rootDir = resolveMatrixLegacyFlatStoreRoot(stateDir);
|
||||
return {
|
||||
rootDir,
|
||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||
cryptoPath: path.join(rootDir, "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountStorageRoot(params: {
|
||||
stateDir: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
rootDir: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
} {
|
||||
const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const userKey = sanitizeMatrixPathSegment(params.userId);
|
||||
const serverKey = resolveMatrixHomeserverKey(params.homeserver);
|
||||
const tokenHash = hashMatrixAccessToken(params.accessToken);
|
||||
return {
|
||||
rootDir: path.join(
|
||||
params.stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
accountKey,
|
||||
`${serverKey}__${userKey}`,
|
||||
tokenHash,
|
||||
),
|
||||
accountKey,
|
||||
tokenHash,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixPlugin } from "../../../extensions/matrix-js/src/channel.js";
|
||||
import { matrixPlugin } from "../../../extensions/matrix/src/channel.js";
|
||||
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
||||
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
@@ -52,7 +52,7 @@ const telegramConfig = {
|
||||
|
||||
const matrixConfig = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "matrix-test",
|
||||
},
|
||||
@@ -102,14 +102,14 @@ const defaultMatrixDmToolContext = {
|
||||
} as const;
|
||||
|
||||
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
|
||||
let setMatrixRuntime: typeof import("../../../extensions/matrix-js/src/runtime.js").setMatrixRuntime;
|
||||
let setMatrixRuntime: typeof import("../../../extensions/matrix/src/runtime.js").setMatrixRuntime;
|
||||
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
|
||||
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
|
||||
|
||||
describe("runMessageAction threading auto-injection", () => {
|
||||
beforeAll(async () => {
|
||||
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
|
||||
({ setMatrixRuntime } = await import("../../../extensions/matrix-js/src/runtime.js"));
|
||||
({ setMatrixRuntime } = await import("../../../extensions/matrix/src/runtime.js"));
|
||||
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
|
||||
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix-js",
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: matrixPlugin,
|
||||
},
|
||||
@@ -278,7 +278,7 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
const call = await runThreadingAction({
|
||||
cfg: matrixConfig,
|
||||
actionParams: {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
target: testCase.target,
|
||||
message: "hi",
|
||||
},
|
||||
@@ -297,7 +297,7 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
const call = await runThreadingAction({
|
||||
cfg: matrixConfig,
|
||||
actionParams: {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
target: "room:!room:example.org",
|
||||
message: "hi",
|
||||
threadId: "$explicit",
|
||||
@@ -315,7 +315,7 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
const call = await runThreadingAction({
|
||||
cfg: matrixConfig,
|
||||
actionParams: {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
target: "user:@alice:example.org",
|
||||
message: "hi",
|
||||
},
|
||||
@@ -332,7 +332,7 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
const call = await runThreadingAction({
|
||||
cfg: matrixConfig,
|
||||
actionParams: {
|
||||
channel: "matrix-js",
|
||||
channel: "matrix",
|
||||
target: "user:@bob:example.org",
|
||||
message: "hi",
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ function resolveAndApplyOutboundThreadId(
|
||||
? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
|
||||
: undefined;
|
||||
const matrixAutoThreadId =
|
||||
ctx.channel === "matrix-js" && !threadId
|
||||
ctx.channel === "matrix" && !threadId
|
||||
? resolveMatrixAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
|
||||
: undefined;
|
||||
const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId ?? matrixAutoThreadId;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
// Narrow plugin-sdk surface for the bundled matrix-js plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/matrix-js.
|
||||
|
||||
export {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "../agents/tools/common.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
||||
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
|
||||
export { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
export type { NormalizedLocation } from "../channels/location.js";
|
||||
export { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||
export { logInboundDrop, logTypingFailure } from "../channels/logging.js";
|
||||
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
|
||||
export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js";
|
||||
export {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
} from "../channels/plugins/channel-config.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||
export type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../channels/plugins/onboarding-types.js";
|
||||
export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js";
|
||||
export {
|
||||
addWildcardAllowFrom,
|
||||
mergeAllowFromEntries,
|
||||
promptSingleChannelSecretInput,
|
||||
} from "../channels/plugins/onboarding/helpers.js";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
||||
export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js";
|
||||
export { migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelToolSend,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export type { ChannelSetupInput } from "../channels/plugins/types.js";
|
||||
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
export { resolveAckReaction } from "../agents/identity.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../config/runtime-group-policy.js";
|
||||
export type {
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownTableMode,
|
||||
} from "../config/types.js";
|
||||
export type { SecretInput } from "../config/types.secrets.js";
|
||||
export {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
|
||||
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
|
||||
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
|
||||
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
export {
|
||||
getSessionBindingService,
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
SessionBindingAdapter,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export type { PollInput } from "../polls.js";
|
||||
export { normalizePollInput } from "../polls.js";
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
export { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js";
|
||||
export { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js";
|
||||
export type { RuntimeEnv } from "../runtime.js";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../security/dm-policy-shared.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||
export { createLoggerBackedRuntime } from "./runtime.js";
|
||||
export { buildProbeChannelStatusSummary } from "./status-helpers.js";
|
||||
export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js";
|
||||
export { promptAccountId } from "../channels/plugins/onboarding/helpers.js";
|
||||
@@ -10,12 +10,20 @@ export {
|
||||
readStringParam,
|
||||
} from "../agents/tools/common.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export { resolveAckReaction } from "../agents/identity.js";
|
||||
export {
|
||||
compileAllowlist,
|
||||
resolveAllowlistCandidates,
|
||||
resolveAllowlistMatchByCandidates,
|
||||
} from "../channels/allowlist-match.js";
|
||||
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
|
||||
resolveConfiguredAcpRoute,
|
||||
ensureConfiguredAcpRouteReady,
|
||||
} from "../acp/persistent-bindings.route.js";
|
||||
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
||||
export {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
mergeAllowlist,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../channels/allowlists/resolve-utils.js";
|
||||
export { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
export type { NormalizedLocation } from "../channels/location.js";
|
||||
export { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||
@@ -41,11 +49,16 @@ export {
|
||||
buildSingleChannelSecretPromptState,
|
||||
addWildcardAllowFrom,
|
||||
mergeAllowFromEntries,
|
||||
promptAccountId,
|
||||
promptSingleChannelSecretInput,
|
||||
setTopLevelChannelGroupPolicy,
|
||||
} from "../channels/plugins/onboarding/helpers.js";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
||||
export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
} from "../channels/plugins/setup-helpers.js";
|
||||
export { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
@@ -57,10 +70,17 @@ export type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelSetupInput,
|
||||
ChannelToolSend,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export {
|
||||
@@ -84,33 +104,66 @@ export {
|
||||
export { buildSecretInputSchema } from "./secret-input-schema.js";
|
||||
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
|
||||
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
|
||||
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
|
||||
export {
|
||||
hashMatrixAccessToken,
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsFilename,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixHomeserverKey,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
sanitizeMatrixPathSegment,
|
||||
} from "../infra/matrix-storage-paths.js";
|
||||
export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveMatrixMigrationSnapshotMarkerPath,
|
||||
resolveMatrixMigrationSnapshotOutputDir,
|
||||
} from "../infra/matrix-migration-snapshot.js";
|
||||
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
export {
|
||||
getSessionBindingService,
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export type {
|
||||
BindingTargetKind,
|
||||
SessionBindingAdapter,
|
||||
SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export type { PollInput } from "../polls.js";
|
||||
export { normalizePollInput } from "../polls.js";
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
export type { RuntimeEnv } from "../runtime.js";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../security/dm-policy-shared.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "./group-access.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js";
|
||||
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||
export {
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
} from "./status-helpers.js";
|
||||
export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js";
|
||||
|
||||
@@ -26,7 +26,6 @@ const bundledExtensionSubpathLoaders = [
|
||||
{ id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") },
|
||||
{ id: "lobster", load: () => import("openclaw/plugin-sdk/lobster") },
|
||||
{ id: "matrix", load: () => import("openclaw/plugin-sdk/matrix") },
|
||||
{ id: "matrix-js", load: () => import("openclaw/plugin-sdk/matrix-js") },
|
||||
{ id: "mattermost", load: () => import("openclaw/plugin-sdk/mattermost") },
|
||||
{ id: "memory-core", load: () => import("openclaw/plugin-sdk/memory-core") },
|
||||
{ id: "memory-lancedb", load: () => import("openclaw/plugin-sdk/memory-lancedb") },
|
||||
|
||||
Reference in New Issue
Block a user