Matrix: wire thread binding command support

This commit is contained in:
Gustavo Madeira Santana 2026-03-19 09:24:24 -04:00
parent 191e1947c1
commit dd10f290e8
No known key found for this signature in database
20 changed files with 756 additions and 56 deletions

View File

@ -372,7 +372,7 @@ Planned improvement:
## Automatic verification notices
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
Matrix now posts verification lifecycle notices directly into the strict DM verification room as `m.notice` messages.
That includes:
- verification request notices
@ -381,7 +381,8 @@ That includes:
- SAS details (emoji and decimal) when available
Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw.
When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side.
For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side.
For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally.
You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification.
OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending.

View File

@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
`- Failed creating a Matrix migration snapshot before repair: ...`
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
- What to do: run `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
`Failed inspecting legacy Matrix encrypted state for account "...": ...`
`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...`
- Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery.
- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.

View File

@ -24,6 +24,10 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "telegram";
}
export function isMatrixSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "matrix";
}
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
const channel =
params.ctx.OriginatingChannel ??

View File

@ -120,7 +120,7 @@ type FakeBinding = {
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord" | "telegram" | "feishu";
channel: "discord" | "matrix" | "telegram" | "feishu";
accountId: string;
conversationId: string;
parentConversationId?: string;
@ -245,9 +245,10 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: {
channel?: "discord" | "telegram" | "feishu";
channel?: "discord" | "matrix" | "telegram" | "feishu";
accountId: string;
conversationId: string;
parentConversationId?: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
conversationId: nextConversationId,
parentConversationId: "parent-1",
}
: channel === "feishu"
: channel === "matrix"
? {
channel: "feishu" as const,
channel: "matrix" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId:
input.placement === "child"
? input.conversation.conversationId
: input.conversation.parentConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
: channel === "feishu"
? {
channel: "feishu" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
return createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation,
@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
}
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = createMatrixRoomParams(commandBody, cfg);
params.ctx.MessageThreadId = "$thread-root";
return params;
}
async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true);
}
async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true);
}
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "feishu",
@ -598,6 +635,63 @@ describe("/acp command", () => {
);
});
it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "!room:example.org",
}),
}),
);
});
it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg);
expect(result?.reply?.text).toContain("Bound this thread to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "$thread-root",
parentConversationId: "!room:example.org",
}),
}),
);
});
it("binds Feishu DM ACP spawns to the current DM conversation", async () => {
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
@ -654,6 +748,24 @@ describe("/acp command", () => {
);
});
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("forbids /acp spawn from sandboxed requester sessions", async () => {
const cfg = {
...baseCfg,

View File

@ -141,6 +141,27 @@ describe("commands-acp context", () => {
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
});
it("resolves Matrix thread context from the current room and thread root", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "work",
MessageThreadId: "$thread-root",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "matrix",
accountId: "work",
threadId: "$thread-root",
conversationId: "$thread-root",
parentConversationId: "!room:example.org",
});
expect(resolveAcpCommandConversationId(params)).toBe("$thread-root");
expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org");
});
it("builds Feishu topic conversation ids from chat target + root message id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "feishu",

View File

@ -9,6 +9,10 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import { resolveTelegramConversationId } from "../telegram-context.js";
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
@ -161,6 +165,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix") {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (channel === "telegram") {
const telegramConversationId = resolveTelegramConversationId({
ctx: {
@ -231,6 +247,18 @@ export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix") {
return resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (channel === "telegram") {
return (
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??

View File

@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: {
}
const senderId = commandParams.command.senderId?.trim() || "";
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
const conversationRef = {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: currentConversationId,
...(parentConversationId && parentConversationId !== currentConversationId
? { parentConversationId }
: {}),
};
if (placement === "current") {
const existingBinding = bindingService.resolveByConversation({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: currentConversationId,
});
const existingBinding = bindingService.resolveByConversation(conversationRef);
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: {
}
const label = params.label || params.agentId;
const conversationId = currentConversationId;
try {
const binding = await bindingService.bind({
targetSessionKey: params.sessionKey,
targetKind: "session",
conversation: {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId,
},
conversation: conversationRef,
placement,
metadata: {
threadName: resolveThreadBindingThreadName({

View File

@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import {
DISCORD_THREAD_BINDING_CHANNEL,
MATRIX_THREAD_BINDING_CHANNEL,
} from "../../../channels/thread-bindings-policy.js";
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
@ -168,7 +171,8 @@ function normalizeAcpOptionToken(raw: string): string {
}
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
const channel = resolveAcpCommandChannel(params);
if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) {
return "off";
}
const currentThreadId = resolveAcpCommandThreadId(params);

View File

@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setMatrixThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => {
getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKeyMock,
setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
setMatrixThreadBindingMaxAgeBySessionKeyMock,
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKeyMock,
sessionBindingResolveByConversationMock,
@ -48,6 +52,12 @@ vi.mock("../../plugins/runtime/index.js", async () => {
setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
},
},
matrix: {
threadBindings: {
setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock,
},
},
},
}),
};
@ -114,6 +124,29 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
});
}
function createMatrixThreadCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
MessageThreadId: "$thread-1",
...overrides,
});
}
function createMatrixRoomCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
...overrides,
});
}
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
const now = Date.now();
return {
@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): Sessi
};
}
function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:$thread-1",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
function expectIdleTimeoutSetReply(
mock: ReturnType<typeof vi.fn>,
text: string,
@ -183,6 +239,8 @@ describe("/session idle and /session max-age", () => {
hoisted.getThreadBindingManagerMock.mockReset();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
@ -286,6 +344,66 @@ describe("/session idle and /session max-age", () => {
);
});
it("sets idle timeout for focused Matrix threads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding());
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createMatrixThreadCommandParams("/session idle 2h"),
true,
);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
});
it("sets max age for focused Matrix threads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createMatrixBinding({ boundAt }),
);
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createMatrixThreadCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T01:00:00.000Z");
});
it("reports Telegram max-age expiry from the original bind time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
@ -340,10 +458,20 @@ describe("/session idle and /session max-age", () => {
const params = buildCommandTestParams("/session idle 2h", baseCfg);
const result = await handleSessionCommand(params, true);
expect(result?.reply?.text).toContain(
"currently available for Discord and Telegram bound sessions",
"currently available for Discord, Matrix, and Telegram bound sessions",
);
});
it("requires a focused Matrix thread for lifecycle updates", async () => {
const result = await handleSessionCommand(
createMatrixRoomCommandParams("/session idle 2h"),
true,
);
expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread");
expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled();
});
it("requires binding owner for lifecycle updates", async () => {
const binding = createFakeBinding({ boundBy: "owner-1" });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));

View File

@ -12,10 +12,19 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js";
import {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
} from "./channel-context.js";
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
import { persistSessionEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "./matrix-context.js";
import { resolveTelegramConversationId } from "./telegram-context.js";
const SESSION_COMMAND_PREFIX = "/session";
@ -55,7 +64,7 @@ function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
}
function resolveTelegramBindingDurationMs(
function resolveSessionBindingDurationMs(
binding: SessionBindingRecord,
key: "idleTimeoutMs" | "maxAgeMs",
fallbackMs: number,
@ -67,7 +76,7 @@ function resolveTelegramBindingDurationMs(
return Math.max(0, Math.floor(raw));
}
function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number {
function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number {
const raw = binding.metadata?.lastActivityAt;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return binding.boundAt;
@ -75,7 +84,7 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu
return Math.max(Math.floor(raw), binding.boundAt);
}
function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string {
function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string {
const raw = binding.metadata?.boundBy;
return typeof raw === "string" ? raw.trim() : "";
}
@ -87,6 +96,46 @@ type UpdatedLifecycleBinding = {
maxAgeMs?: number;
};
function isSessionBindingRecord(
binding: UpdatedLifecycleBinding | SessionBindingRecord,
): binding is SessionBindingRecord {
return "bindingId" in binding;
}
function resolveUpdatedLifecycleDurationMs(
binding: UpdatedLifecycleBinding | SessionBindingRecord,
key: "idleTimeoutMs" | "maxAgeMs",
): number | undefined {
if (!isSessionBindingRecord(binding)) {
const raw = binding[key];
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(0, Math.floor(raw));
}
}
if (!isSessionBindingRecord(binding)) {
return undefined;
}
const raw = binding.metadata?.[key];
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
return Math.max(0, Math.floor(raw));
}
function toUpdatedLifecycleBinding(
binding: UpdatedLifecycleBinding | SessionBindingRecord,
): UpdatedLifecycleBinding {
const lastActivityAt = isSessionBindingRecord(binding)
? resolveSessionBindingLastActivityAt(binding)
: Math.max(Math.floor(binding.lastActivityAt), binding.boundAt);
return {
boundAt: binding.boundAt,
lastActivityAt,
idleTimeoutMs: resolveUpdatedLifecycleDurationMs(binding, "idleTimeoutMs"),
maxAgeMs: resolveUpdatedLifecycleDurationMs(binding, "maxAgeMs"),
};
}
function resolveUpdatedBindingExpiry(params: {
action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE;
bindings: UpdatedLifecycleBinding[];
@ -363,12 +412,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
const onDiscord = isDiscordSurface(params);
const onMatrix = isMatrixSurface(params);
const onTelegram = isTelegramSurface(params);
if (!onDiscord && !onTelegram) {
if (!onDiscord && !onMatrix && !onTelegram) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.",
text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.",
},
};
}
@ -377,6 +427,30 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const sessionBindingService = getSessionBindingService();
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const matrixConversationId = onMatrix
? resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: undefined;
const matrixParentConversationId = onMatrix
? resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: undefined;
const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined;
const channelRuntime = getChannelRuntime();
@ -400,6 +474,17 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
conversationId: telegramConversationId,
})
: null;
const matrixBinding =
onMatrix && matrixConversationId
? sessionBindingService.resolveByConversation({
channel: "matrix",
accountId,
conversationId: matrixConversationId,
...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId
? { parentConversationId: matrixParentConversationId }
: {}),
})
: null;
if (onDiscord && !discordBinding) {
if (onDiscord && !threadId) {
return {
@ -414,6 +499,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
reply: { text: " This thread is not currently focused." },
};
}
if (onMatrix && !matrixBinding) {
if (!threadId) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.",
},
};
}
return {
shouldContinue: false,
reply: { text: " This thread is not currently focused." },
};
}
if (onTelegram && !telegramBinding) {
if (!telegramConversationId) {
return {
@ -434,28 +533,33 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000);
: resolveSessionBindingDurationMs(
(onMatrix ? matrixBinding : telegramBinding)!,
"idleTimeoutMs",
24 * 60 * 60 * 1000,
);
const idleExpiresAt = onDiscord
? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: idleTimeoutMs > 0
? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs
? resolveSessionBindingLastActivityAt((onMatrix ? matrixBinding : telegramBinding)!) +
idleTimeoutMs
: undefined;
const maxAgeMs = onDiscord
? channelRuntime.discord.threadBindings.resolveMaxAgeMs({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0);
: resolveSessionBindingDurationMs((onMatrix ? matrixBinding : telegramBinding)!, "maxAgeMs", 0);
const maxAgeExpiresAt = onDiscord
? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: maxAgeMs > 0
? telegramBinding!.boundAt + maxAgeMs
? (onMatrix ? matrixBinding : telegramBinding)!.boundAt + maxAgeMs
: undefined;
const durationArgRaw = tokens.slice(1).join("");
@ -500,14 +604,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const senderId = params.command.senderId?.trim() || "";
const boundBy = onDiscord
? discordBinding!.boundBy
: resolveTelegramBindingBoundBy(telegramBinding!);
: resolveSessionBindingBoundBy((onMatrix ? matrixBinding : telegramBinding)!);
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
shouldContinue: false,
reply: {
text: onDiscord
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
: onMatrix
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
},
};
}
@ -536,6 +642,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
maxAgeMs: durationMs,
});
}
if (onMatrix) {
return action === SESSION_ACTION_IDLE
? channelRuntime.matrix.threadBindings.setIdleTimeoutBySessionKey({
targetSessionKey: matrixBinding!.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: channelRuntime.matrix.threadBindings.setMaxAgeBySessionKey({
targetSessionKey: matrixBinding!.targetSessionKey,
accountId,
maxAgeMs: durationMs,
});
}
return action === SESSION_ACTION_IDLE
? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({
targetSessionKey: telegramBinding!.targetSessionKey,
@ -574,7 +693,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const nextExpiry = resolveUpdatedBindingExpiry({
action,
bindings: updatedBindings,
bindings: updatedBindings.map((binding) => toUpdatedLifecycleBinding(binding)),
});
const expiryLabel =
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)

View File

@ -103,6 +103,31 @@ function createTelegramTopicCommandParams(commandBody: string) {
return params;
}
function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
MessageThreadId: "$thread-1",
});
params.command.senderId = "user-1";
return params;
}
function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
function createSessionBindingRecord(
overrides?: Partial<SessionBindingRecord>,
): SessionBindingRecord {
@ -144,7 +169,13 @@ async function focusCodexAcp(
hoisted.sessionBindingBindMock.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { channel: string; accountId: string; conversationId: string };
placement: "current" | "child";
conversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
};
metadata?: Record<string, unknown>;
}) =>
createSessionBindingRecord({
@ -152,7 +183,11 @@ async function focusCodexAcp(
conversation: {
channel: input.conversation.channel,
accountId: input.conversation.accountId,
conversationId: input.conversation.conversationId,
conversationId:
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
...(input.conversation.parentConversationId
? { parentConversationId: input.conversation.parentConversationId }
: {}),
},
metadata: {
boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
@ -220,6 +255,51 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
expect(result?.reply?.text).toContain("created thread thread-created and bound it");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
conversationId: "!room:example.org",
}),
}),
);
});
it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
expect(result?.reply?.text).toContain("spawnSubagentSessions=true");
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("/focus includes ACP session identifiers in intro text when available", async () => {
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
@ -283,6 +363,36 @@ describe("/focus, /unfocus, /agents", () => {
});
});
it("/unfocus removes an active Matrix thread binding for the binding owner", async () => {
const params = createMatrixThreadCommandParams("/unfocus");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBindingRecord({
bindingId: "default:matrix-thread-1",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
},
metadata: { boundBy: "user-1" },
}),
);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
});
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({
bindingId: "default:matrix-thread-1",
reason: "manual",
});
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
const result = await focusCodexAcp(undefined, {
existingBinding: createSessionBindingRecord({
@ -401,6 +511,6 @@ describe("/focus, /unfocus, /agents", () => {
it("/focus rejects unsupported channels", async () => {
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("only available on Discord and Telegram");
expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram");
});
});

View File

@ -8,14 +8,22 @@ import {
resolveThreadBindingThreadName,
} from "../../../channels/thread-bindings-messages.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../../../channels/thread-bindings-policy.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
@ -26,9 +34,10 @@ import {
} from "./shared.js";
type FocusBindingContext = {
channel: "discord" | "telegram";
channel: "discord" | "matrix" | "telegram";
accountId: string;
conversationId: string;
parentConversationId?: string;
placement: "current" | "child";
labelNoun: "thread" | "conversation";
};
@ -65,6 +74,41 @@ function resolveFocusBindingContext(
labelNoun: "conversation",
};
}
if (isMatrixSurface(params)) {
const conversationId = resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
if (!conversationId) {
return null;
}
const parentConversationId = resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
const currentThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
return {
channel: "matrix",
accountId: resolveChannelAccountId(params),
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
placement: currentThreadId ? "current" : "child",
labelNoun: "thread",
};
}
return null;
}
@ -73,8 +117,8 @@ export async function handleSubagentsFocusAction(
): Promise<CommandHandlerResult> {
const { params, runs, restTokens } = ctx;
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram") {
return stopWithText("⚠️ /focus is only available on Discord and Telegram.");
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
}
const token = restTokens.join(" ").trim();
@ -89,7 +133,12 @@ export async function handleSubagentsFocusAction(
accountId,
});
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
const label = channel === "discord" ? "Discord thread" : "Telegram conversation";
const label =
channel === "discord"
? "Discord thread"
: channel === "matrix"
? "Matrix thread"
: "Telegram conversation";
return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`);
}
@ -105,14 +154,48 @@ export async function handleSubagentsFocusAction(
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
}
if (channel === "matrix") {
return stopWithText("⚠️ Could not resolve a Matrix room for /focus.");
}
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
}
if (channel === "matrix") {
const spawnPolicy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel,
accountId: bindingContext.accountId,
kind: "subagent",
});
if (!spawnPolicy.enabled) {
return stopWithText(
`⚠️ ${formatThreadBindingDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "subagent",
})}`,
);
}
if (bindingContext.placement === "child" && !spawnPolicy.spawnEnabled) {
return stopWithText(
`⚠️ ${formatThreadBindingSpawnDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "subagent",
})}`,
);
}
}
const senderId = params.command.senderId?.trim() || "";
const existingBinding = bindingService.resolveByConversation({
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId &&
bindingContext.parentConversationId !== bindingContext.conversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
@ -143,6 +226,10 @@ export async function handleSubagentsFocusAction(
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId &&
bindingContext.parentConversationId !== bindingContext.conversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
},
placement: bindingContext.placement,
metadata: {

View File

@ -1,8 +1,13 @@
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
@ -15,8 +20,8 @@ export async function handleSubagentsUnfocusAction(
): Promise<CommandHandlerResult> {
const { params } = ctx;
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram") {
return stopWithText("⚠️ /unfocus is only available on Discord and Telegram.");
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
}
const accountId = resolveChannelAccountId(params);
@ -30,13 +35,43 @@ export async function handleSubagentsUnfocusAction(
if (isTelegramSurface(params)) {
return resolveTelegramConversationId(params);
}
if (isMatrixSurface(params)) {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
return undefined;
})();
const parentConversationId = (() => {
if (!isMatrixSurface(params)) {
return undefined;
}
return resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
})();
if (!conversationId) {
if (channel === "discord") {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
}
if (channel === "matrix") {
return stopWithText("⚠️ /unfocus must be run inside a Matrix thread.");
}
return stopWithText(
"⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
@ -46,12 +81,17 @@ export async function handleSubagentsUnfocusAction(
channel,
accountId,
conversationId,
...(parentConversationId && parentConversationId !== conversationId
? { parentConversationId }
: {}),
});
if (!binding) {
return stopWithText(
channel === "discord"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
: channel === "matrix"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
);
}
@ -62,7 +102,9 @@ export async function handleSubagentsUnfocusAction(
return stopWithText(
channel === "discord"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
: channel === "matrix"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
);
}
@ -71,6 +113,8 @@ export async function handleSubagentsUnfocusAction(
reason: "manual",
});
return stopWithText(
channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.",
channel === "discord" || channel === "matrix"
? "✅ Thread unfocused."
: "✅ Conversation unfocused.",
);
}

View File

@ -30,6 +30,7 @@ import {
} from "../../../shared/subagents-format.js";
import {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,
@ -47,6 +48,7 @@ import { resolveTelegramConversationId } from "../telegram-context.js";
export { extractAssistantText, stripToolMessages };
export {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,

View File

@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { normalizeAccountId } from "../routing/session-key.js";
export const DISCORD_THREAD_BINDING_CHANNEL = "discord";
export const MATRIX_THREAD_BINDING_CHANNEL = "matrix";
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
@ -127,8 +128,9 @@ export function resolveThreadBindingSpawnPolicy(params: {
const spawnFlagKey = resolveSpawnFlagKey(params.kind);
const spawnEnabledRaw =
normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]);
// Non-Discord channels currently have no dedicated spawn gate config keys.
const spawnEnabled = spawnEnabledRaw ?? channel !== DISCORD_THREAD_BINDING_CHANNEL;
const spawnEnabled =
spawnEnabledRaw ??
(channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL);
return {
channel,
accountId,
@ -183,6 +185,9 @@ export function formatThreadBindingDisabledError(params: {
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL) {
return "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).";
}
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL) {
return "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).";
}
return `Thread bindings are disabled for ${params.channel} (set session.threadBindings.enabled=true to enable).`;
}
@ -197,5 +202,11 @@ export function formatThreadBindingSpawnDisabledError(params: {
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") {
return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).";
}
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "acp") {
return "Matrix thread-bound ACP spawns are disabled for this account (set channels.matrix.threadBindings.spawnAcpSessions=true to enable).";
}
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "subagent") {
return "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable).";
}
return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`;
}

View File

@ -82,6 +82,10 @@ export {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "../channels/thread-bindings-policy.js";
export {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "../../extensions/matrix/src/matrix/thread-bindings.js";
export { createTypingCallbacks } from "../channels/typing.js";
export { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
export type { OpenClawConfig } from "../config/config.js";

View File

@ -78,6 +78,7 @@ import {
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
import { createRuntimeDiscord } from "./runtime-discord.js";
import { createRuntimeIMessage } from "./runtime-imessage.js";
import { createRuntimeMatrix } from "./runtime-matrix.js";
import { createRuntimeSignal } from "./runtime-signal.js";
import { createRuntimeSlack } from "./runtime-slack.js";
import { createRuntimeTelegram } from "./runtime-telegram.js";
@ -206,18 +207,19 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
},
} satisfies Omit<
PluginRuntime["channel"],
"discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp"
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp"
> &
Partial<
Pick<
PluginRuntime["channel"],
"discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp"
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp"
>
>;
defineCachedValue(channelRuntime, "discord", createRuntimeDiscord);
defineCachedValue(channelRuntime, "slack", createRuntimeSlack);
defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram);
defineCachedValue(channelRuntime, "matrix", createRuntimeMatrix);
defineCachedValue(channelRuntime, "signal", createRuntimeSignal);
defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage);
defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp);

View File

@ -0,0 +1,14 @@
import {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "openclaw/plugin-sdk/matrix";
import type { PluginRuntimeChannel } from "./types-channel.js";
export function createRuntimeMatrix(): PluginRuntimeChannel["matrix"] {
return {
threadBindings: {
setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKey,
setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKey,
},
};
}

View File

@ -193,6 +193,12 @@ export type PluginRuntimeChannel = {
unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram;
};
};
matrix: {
threadBindings: {
setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey;
setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey;
};
};
signal: {
probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal;
sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal;

View File

@ -297,6 +297,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
matrix: {} as PluginRuntime["channel"]["matrix"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],