Matrix: wire thread binding command support
This commit is contained in:
parent
191e1947c1
commit
dd10f290e8
@ -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.
|
||||
|
||||
@ -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>"`.
|
||||
|
||||
@ -24,6 +24,10 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
|
||||
return resolveCommandSurfaceChannel(params) === "telegram";
|
||||
}
|
||||
|
||||
export function isMatrixSurface(params: DiscordSurfaceParams): boolean {
|
||||
return resolveCommandSurfaceChannel(params) === "matrix";
|
||||
}
|
||||
|
||||
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
|
||||
const channel =
|
||||
params.ctx.OriginatingChannel ??
|
||||
|
||||
@ -120,7 +120,7 @@ type FakeBinding = {
|
||||
targetSessionKey: string;
|
||||
targetKind: "subagent" | "session";
|
||||
conversation: {
|
||||
channel: "discord" | "telegram" | "feishu";
|
||||
channel: "discord" | "matrix" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@ -245,9 +245,10 @@ function createSessionBindingCapabilities() {
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: {
|
||||
channel?: "discord" | "telegram" | "feishu";
|
||||
channel?: "discord" | "matrix" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
}
|
||||
: channel === "feishu"
|
||||
: channel === "matrix"
|
||||
? {
|
||||
channel: "feishu" as const,
|
||||
channel: "matrix" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId:
|
||||
input.placement === "child"
|
||||
? input.conversation.conversationId
|
||||
: input.conversation.parentConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
: channel === "feishu"
|
||||
? {
|
||||
channel: "feishu" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation,
|
||||
@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
|
||||
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = createMatrixRoomParams(commandBody, cfg);
|
||||
params.ctx.MessageThreadId = "$thread-root";
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "feishu",
|
||||
@ -598,6 +635,63 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "child",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Bound this thread to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-root",
|
||||
parentConversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Feishu DM ACP spawns to the current DM conversation", async () => {
|
||||
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
|
||||
|
||||
@ -654,6 +748,24 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids /acp spawn from sandboxed requester sessions", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
|
||||
@ -141,6 +141,27 @@ describe("commands-acp context", () => {
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
|
||||
});
|
||||
|
||||
it("resolves Matrix thread context from the current room and thread root", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "work",
|
||||
MessageThreadId: "$thread-root",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "matrix",
|
||||
accountId: "work",
|
||||
threadId: "$thread-root",
|
||||
conversationId: "$thread-root",
|
||||
parentConversationId: "!room:example.org",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("$thread-root");
|
||||
expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org");
|
||||
});
|
||||
|
||||
it("builds Feishu topic conversation ids from chat target + root message id", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
|
||||
@ -9,6 +9,10 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "../matrix-context.js";
|
||||
import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
|
||||
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
||||
@ -161,6 +165,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
|
||||
|
||||
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "matrix") {
|
||||
return resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (channel === "telegram") {
|
||||
const telegramConversationId = resolveTelegramConversationId({
|
||||
ctx: {
|
||||
@ -231,6 +247,18 @@ export function resolveAcpCommandParentConversationId(
|
||||
params: HandleCommandsParams,
|
||||
): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "matrix") {
|
||||
return resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (channel === "telegram") {
|
||||
return (
|
||||
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??
|
||||
|
||||
@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const senderId = commandParams.command.senderId?.trim() || "";
|
||||
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
|
||||
const conversationRef = {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId && parentConversationId !== currentConversationId
|
||||
? { parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
if (placement === "current") {
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
});
|
||||
const existingBinding = bindingService.resolveByConversation(conversationRef);
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
? existingBinding.metadata.boundBy.trim()
|
||||
@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const label = params.label || params.agentId;
|
||||
const conversationId = currentConversationId;
|
||||
|
||||
try {
|
||||
const binding = await bindingService.bind({
|
||||
targetSessionKey: params.sessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId,
|
||||
},
|
||||
conversation: conversationRef,
|
||||
placement,
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
|
||||
@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
|
||||
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
|
||||
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import {
|
||||
DISCORD_THREAD_BINDING_CHANNEL,
|
||||
MATRIX_THREAD_BINDING_CHANNEL,
|
||||
} from "../../../channels/thread-bindings-policy.js";
|
||||
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
|
||||
import { normalizeAgentId } from "../../../routing/session-key.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
|
||||
@ -168,7 +171,8 @@ function normalizeAcpOptionToken(raw: string): string {
|
||||
}
|
||||
|
||||
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
|
||||
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) {
|
||||
return "off";
|
||||
}
|
||||
const currentThreadId = resolveAcpCommandThreadId(params);
|
||||
|
||||
@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => {
|
||||
const getThreadBindingManagerMock = vi.fn();
|
||||
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setMatrixThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
|
||||
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
|
||||
const sessionBindingResolveByConversationMock = vi.fn();
|
||||
@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => {
|
||||
getThreadBindingManagerMock,
|
||||
setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setThreadBindingMaxAgeBySessionKeyMock,
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
sessionBindingResolveByConversationMock,
|
||||
@ -48,6 +52,12 @@ vi.mock("../../plugins/runtime/index.js", async () => {
|
||||
setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@ -114,6 +124,29 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixThreadCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "$thread-1",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixRoomCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): Sessi
|
||||
};
|
||||
}
|
||||
|
||||
function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "default:$thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectIdleTimeoutSetReply(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
text: string,
|
||||
@ -183,6 +239,8 @@ describe("/session idle and /session max-age", () => {
|
||||
hoisted.getThreadBindingManagerMock.mockReset();
|
||||
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
|
||||
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
|
||||
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReset();
|
||||
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
|
||||
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
|
||||
@ -286,6 +344,66 @@ describe("/session idle and /session max-age", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets idle timeout for focused Matrix threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding());
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixThreadCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets max age for focused Matrix threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createMatrixBinding({ boundAt }),
|
||||
);
|
||||
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt,
|
||||
lastActivityAt: Date.now(),
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixThreadCommandParams("/session max-age 3h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
maxAgeMs: 3 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Max age set to 3h");
|
||||
expect(text).toContain("2026-02-20T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("reports Telegram max-age expiry from the original bind time", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
@ -340,10 +458,20 @@ describe("/session idle and /session max-age", () => {
|
||||
const params = buildCommandTestParams("/session idle 2h", baseCfg);
|
||||
const result = await handleSessionCommand(params, true);
|
||||
expect(result?.reply?.text).toContain(
|
||||
"currently available for Discord and Telegram bound sessions",
|
||||
"currently available for Discord, Matrix, and Telegram bound sessions",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a focused Matrix thread for lifecycle updates", async () => {
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixRoomCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread");
|
||||
expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires binding owner for lifecycle updates", async () => {
|
||||
const binding = createFakeBinding({ boundBy: "owner-1" });
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
|
||||
|
||||
@ -12,10 +12,19 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
|
||||
import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js";
|
||||
import {
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveChannelAccountId,
|
||||
} from "./channel-context.js";
|
||||
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
|
||||
import { persistSessionEntry } from "./commands-session-store.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "./matrix-context.js";
|
||||
import { resolveTelegramConversationId } from "./telegram-context.js";
|
||||
|
||||
const SESSION_COMMAND_PREFIX = "/session";
|
||||
@ -55,7 +64,7 @@ function formatSessionExpiry(expiresAt: number) {
|
||||
return new Date(expiresAt).toISOString();
|
||||
}
|
||||
|
||||
function resolveTelegramBindingDurationMs(
|
||||
function resolveSessionBindingDurationMs(
|
||||
binding: SessionBindingRecord,
|
||||
key: "idleTimeoutMs" | "maxAgeMs",
|
||||
fallbackMs: number,
|
||||
@ -67,7 +76,7 @@ function resolveTelegramBindingDurationMs(
|
||||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
|
||||
function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number {
|
||||
function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number {
|
||||
const raw = binding.metadata?.lastActivityAt;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return binding.boundAt;
|
||||
@ -75,7 +84,7 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu
|
||||
return Math.max(Math.floor(raw), binding.boundAt);
|
||||
}
|
||||
|
||||
function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string {
|
||||
function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string {
|
||||
const raw = binding.metadata?.boundBy;
|
||||
return typeof raw === "string" ? raw.trim() : "";
|
||||
}
|
||||
@ -87,6 +96,46 @@ type UpdatedLifecycleBinding = {
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
function isSessionBindingRecord(
|
||||
binding: UpdatedLifecycleBinding | SessionBindingRecord,
|
||||
): binding is SessionBindingRecord {
|
||||
return "bindingId" in binding;
|
||||
}
|
||||
|
||||
function resolveUpdatedLifecycleDurationMs(
|
||||
binding: UpdatedLifecycleBinding | SessionBindingRecord,
|
||||
key: "idleTimeoutMs" | "maxAgeMs",
|
||||
): number | undefined {
|
||||
if (!isSessionBindingRecord(binding)) {
|
||||
const raw = binding[key];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
}
|
||||
if (!isSessionBindingRecord(binding)) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = binding.metadata?.[key];
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
|
||||
function toUpdatedLifecycleBinding(
|
||||
binding: UpdatedLifecycleBinding | SessionBindingRecord,
|
||||
): UpdatedLifecycleBinding {
|
||||
const lastActivityAt = isSessionBindingRecord(binding)
|
||||
? resolveSessionBindingLastActivityAt(binding)
|
||||
: Math.max(Math.floor(binding.lastActivityAt), binding.boundAt);
|
||||
return {
|
||||
boundAt: binding.boundAt,
|
||||
lastActivityAt,
|
||||
idleTimeoutMs: resolveUpdatedLifecycleDurationMs(binding, "idleTimeoutMs"),
|
||||
maxAgeMs: resolveUpdatedLifecycleDurationMs(binding, "maxAgeMs"),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveUpdatedBindingExpiry(params: {
|
||||
action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE;
|
||||
bindings: UpdatedLifecycleBinding[];
|
||||
@ -363,12 +412,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
}
|
||||
|
||||
const onDiscord = isDiscordSurface(params);
|
||||
const onMatrix = isMatrixSurface(params);
|
||||
const onTelegram = isTelegramSurface(params);
|
||||
if (!onDiscord && !onTelegram) {
|
||||
if (!onDiscord && !onMatrix && !onTelegram) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.",
|
||||
text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -377,6 +427,30 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
const sessionBindingService = getSessionBindingService();
|
||||
const threadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
const matrixConversationId = onMatrix
|
||||
? resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const matrixParentConversationId = onMatrix
|
||||
? resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined;
|
||||
const channelRuntime = getChannelRuntime();
|
||||
|
||||
@ -400,6 +474,17 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
conversationId: telegramConversationId,
|
||||
})
|
||||
: null;
|
||||
const matrixBinding =
|
||||
onMatrix && matrixConversationId
|
||||
? sessionBindingService.resolveByConversation({
|
||||
channel: "matrix",
|
||||
accountId,
|
||||
conversationId: matrixConversationId,
|
||||
...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId
|
||||
? { parentConversationId: matrixParentConversationId }
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
if (onDiscord && !discordBinding) {
|
||||
if (onDiscord && !threadId) {
|
||||
return {
|
||||
@ -414,6 +499,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
reply: { text: "ℹ️ This thread is not currently focused." },
|
||||
};
|
||||
}
|
||||
if (onMatrix && !matrixBinding) {
|
||||
if (!threadId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ This thread is not currently focused." },
|
||||
};
|
||||
}
|
||||
if (onTelegram && !telegramBinding) {
|
||||
if (!telegramConversationId) {
|
||||
return {
|
||||
@ -434,28 +533,33 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
record: discordBinding!,
|
||||
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
|
||||
})
|
||||
: resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000);
|
||||
: resolveSessionBindingDurationMs(
|
||||
(onMatrix ? matrixBinding : telegramBinding)!,
|
||||
"idleTimeoutMs",
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
const idleExpiresAt = onDiscord
|
||||
? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({
|
||||
record: discordBinding!,
|
||||
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
|
||||
})
|
||||
: idleTimeoutMs > 0
|
||||
? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs
|
||||
? resolveSessionBindingLastActivityAt((onMatrix ? matrixBinding : telegramBinding)!) +
|
||||
idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeMs = onDiscord
|
||||
? channelRuntime.discord.threadBindings.resolveMaxAgeMs({
|
||||
record: discordBinding!,
|
||||
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
|
||||
})
|
||||
: resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0);
|
||||
: resolveSessionBindingDurationMs((onMatrix ? matrixBinding : telegramBinding)!, "maxAgeMs", 0);
|
||||
const maxAgeExpiresAt = onDiscord
|
||||
? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({
|
||||
record: discordBinding!,
|
||||
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
|
||||
})
|
||||
: maxAgeMs > 0
|
||||
? telegramBinding!.boundAt + maxAgeMs
|
||||
? (onMatrix ? matrixBinding : telegramBinding)!.boundAt + maxAgeMs
|
||||
: undefined;
|
||||
|
||||
const durationArgRaw = tokens.slice(1).join("");
|
||||
@ -500,14 +604,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
const boundBy = onDiscord
|
||||
? discordBinding!.boundBy
|
||||
: resolveTelegramBindingBoundBy(telegramBinding!);
|
||||
: resolveSessionBindingBoundBy((onMatrix ? matrixBinding : telegramBinding)!);
|
||||
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: onDiscord
|
||||
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
|
||||
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
|
||||
: onMatrix
|
||||
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
|
||||
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -536,6 +642,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
maxAgeMs: durationMs,
|
||||
});
|
||||
}
|
||||
if (onMatrix) {
|
||||
return action === SESSION_ACTION_IDLE
|
||||
? channelRuntime.matrix.threadBindings.setIdleTimeoutBySessionKey({
|
||||
targetSessionKey: matrixBinding!.targetSessionKey,
|
||||
accountId,
|
||||
idleTimeoutMs: durationMs,
|
||||
})
|
||||
: channelRuntime.matrix.threadBindings.setMaxAgeBySessionKey({
|
||||
targetSessionKey: matrixBinding!.targetSessionKey,
|
||||
accountId,
|
||||
maxAgeMs: durationMs,
|
||||
});
|
||||
}
|
||||
return action === SESSION_ACTION_IDLE
|
||||
? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({
|
||||
targetSessionKey: telegramBinding!.targetSessionKey,
|
||||
@ -574,7 +693,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
|
||||
const nextExpiry = resolveUpdatedBindingExpiry({
|
||||
action,
|
||||
bindings: updatedBindings,
|
||||
bindings: updatedBindings.map((binding) => toUpdatedLifecycleBinding(binding)),
|
||||
});
|
||||
const expiryLabel =
|
||||
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)
|
||||
|
||||
@ -103,6 +103,31 @@ function createTelegramTopicCommandParams(commandBody: string) {
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "$thread-1",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createSessionBindingRecord(
|
||||
overrides?: Partial<SessionBindingRecord>,
|
||||
): SessionBindingRecord {
|
||||
@ -144,7 +169,13 @@ async function focusCodexAcp(
|
||||
hoisted.sessionBindingBindMock.mockImplementation(
|
||||
async (input: {
|
||||
targetSessionKey: string;
|
||||
conversation: { channel: string; accountId: string; conversationId: string };
|
||||
placement: "current" | "child";
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}) =>
|
||||
createSessionBindingRecord({
|
||||
@ -152,7 +183,11 @@ async function focusCodexAcp(
|
||||
conversation: {
|
||||
channel: input.conversation.channel,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: input.conversation.conversationId,
|
||||
conversationId:
|
||||
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
|
||||
...(input.conversation.parentConversationId
|
||||
? { parentConversationId: input.conversation.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
metadata: {
|
||||
boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
|
||||
@ -220,6 +255,51 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
|
||||
|
||||
expect(result?.reply?.text).toContain("created thread thread-created and bound it");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "child",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
conversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnSubagentSessions=true");
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("/focus includes ACP session identifiers in intro text when available", async () => {
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
@ -283,6 +363,36 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/unfocus removes an active Matrix thread binding for the binding owner", async () => {
|
||||
const params = createMatrixThreadCommandParams("/unfocus");
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBindingRecord({
|
||||
bindingId: "default:matrix-thread-1",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
metadata: { boundBy: "user-1" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Thread unfocused");
|
||||
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-1",
|
||||
parentConversationId: "!room:example.org",
|
||||
});
|
||||
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({
|
||||
bindingId: "default:matrix-thread-1",
|
||||
reason: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("/focus rejects rebinding when the thread is focused by another user", async () => {
|
||||
const result = await focusCodexAcp(undefined, {
|
||||
existingBinding: createSessionBindingRecord({
|
||||
@ -401,6 +511,6 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
it("/focus rejects unsupported channels", async () => {
|
||||
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("only available on Discord and Telegram");
|
||||
expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram");
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,14 +8,22 @@ import {
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../../../channels/thread-bindings-messages.js";
|
||||
import {
|
||||
formatThreadBindingDisabledError,
|
||||
formatThreadBindingSpawnDisabledError,
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "../../../channels/thread-bindings-policy.js";
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "../matrix-context.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveChannelAccountId,
|
||||
resolveCommandSurfaceChannel,
|
||||
@ -26,9 +34,10 @@ import {
|
||||
} from "./shared.js";
|
||||
|
||||
type FocusBindingContext = {
|
||||
channel: "discord" | "telegram";
|
||||
channel: "discord" | "matrix" | "telegram";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
placement: "current" | "child";
|
||||
labelNoun: "thread" | "conversation";
|
||||
};
|
||||
@ -65,6 +74,41 @@ function resolveFocusBindingContext(
|
||||
labelNoun: "conversation",
|
||||
};
|
||||
}
|
||||
if (isMatrixSurface(params)) {
|
||||
const conversationId = resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
const parentConversationId = resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
const currentThreadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
return {
|
||||
channel: "matrix",
|
||||
accountId: resolveChannelAccountId(params),
|
||||
conversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
placement: currentThreadId ? "current" : "child",
|
||||
labelNoun: "thread",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -73,8 +117,8 @@ export async function handleSubagentsFocusAction(
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
const channel = resolveCommandSurfaceChannel(params);
|
||||
if (channel !== "discord" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /focus is only available on Discord and Telegram.");
|
||||
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
|
||||
}
|
||||
|
||||
const token = restTokens.join(" ").trim();
|
||||
@ -89,7 +133,12 @@ export async function handleSubagentsFocusAction(
|
||||
accountId,
|
||||
});
|
||||
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
|
||||
const label = channel === "discord" ? "Discord thread" : "Telegram conversation";
|
||||
const label =
|
||||
channel === "discord"
|
||||
? "Discord thread"
|
||||
: channel === "matrix"
|
||||
? "Matrix thread"
|
||||
: "Telegram conversation";
|
||||
return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`);
|
||||
}
|
||||
|
||||
@ -105,14 +154,48 @@ export async function handleSubagentsFocusAction(
|
||||
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
|
||||
);
|
||||
}
|
||||
if (channel === "matrix") {
|
||||
return stopWithText("⚠️ Could not resolve a Matrix room for /focus.");
|
||||
}
|
||||
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
|
||||
}
|
||||
|
||||
if (channel === "matrix") {
|
||||
const spawnPolicy = resolveThreadBindingSpawnPolicy({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId: bindingContext.accountId,
|
||||
kind: "subagent",
|
||||
});
|
||||
if (!spawnPolicy.enabled) {
|
||||
return stopWithText(
|
||||
`⚠️ ${formatThreadBindingDisabledError({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
kind: "subagent",
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
if (bindingContext.placement === "child" && !spawnPolicy.spawnEnabled) {
|
||||
return stopWithText(
|
||||
`⚠️ ${formatThreadBindingSpawnDisabledError({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
kind: "subagent",
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
...(bindingContext.parentConversationId &&
|
||||
bindingContext.parentConversationId !== bindingContext.conversationId
|
||||
? { parentConversationId: bindingContext.parentConversationId }
|
||||
: {}),
|
||||
});
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
@ -143,6 +226,10 @@ export async function handleSubagentsFocusAction(
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
...(bindingContext.parentConversationId &&
|
||||
bindingContext.parentConversationId !== bindingContext.conversationId
|
||||
? { parentConversationId: bindingContext.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
placement: bindingContext.placement,
|
||||
metadata: {
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
resolveMatrixConversationId,
|
||||
resolveMatrixParentConversationId,
|
||||
} from "../matrix-context.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveChannelAccountId,
|
||||
resolveCommandSurfaceChannel,
|
||||
@ -15,8 +20,8 @@ export async function handleSubagentsUnfocusAction(
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params } = ctx;
|
||||
const channel = resolveCommandSurfaceChannel(params);
|
||||
if (channel !== "discord" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord and Telegram.");
|
||||
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
|
||||
}
|
||||
|
||||
const accountId = resolveChannelAccountId(params);
|
||||
@ -30,13 +35,43 @@ export async function handleSubagentsUnfocusAction(
|
||||
if (isTelegramSurface(params)) {
|
||||
return resolveTelegramConversationId(params);
|
||||
}
|
||||
if (isMatrixSurface(params)) {
|
||||
return resolveMatrixConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const parentConversationId = (() => {
|
||||
if (!isMatrixSurface(params)) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveMatrixParentConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
if (!conversationId) {
|
||||
if (channel === "discord") {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
|
||||
}
|
||||
if (channel === "matrix") {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Matrix thread.");
|
||||
}
|
||||
return stopWithText(
|
||||
"⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.",
|
||||
);
|
||||
@ -46,12 +81,17 @@ export async function handleSubagentsUnfocusAction(
|
||||
channel,
|
||||
accountId,
|
||||
conversationId,
|
||||
...(parentConversationId && parentConversationId !== conversationId
|
||||
? { parentConversationId }
|
||||
: {}),
|
||||
});
|
||||
if (!binding) {
|
||||
return stopWithText(
|
||||
channel === "discord"
|
||||
? "ℹ️ This thread is not currently focused."
|
||||
: "ℹ️ This conversation is not currently focused.",
|
||||
: channel === "matrix"
|
||||
? "ℹ️ This thread is not currently focused."
|
||||
: "ℹ️ This conversation is not currently focused.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,7 +102,9 @@ export async function handleSubagentsUnfocusAction(
|
||||
return stopWithText(
|
||||
channel === "discord"
|
||||
? `⚠️ Only ${boundBy} can unfocus this thread.`
|
||||
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
|
||||
: channel === "matrix"
|
||||
? `⚠️ Only ${boundBy} can unfocus this thread.`
|
||||
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -71,6 +113,8 @@ export async function handleSubagentsUnfocusAction(
|
||||
reason: "manual",
|
||||
});
|
||||
return stopWithText(
|
||||
channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.",
|
||||
channel === "discord" || channel === "matrix"
|
||||
? "✅ Thread unfocused."
|
||||
: "✅ Conversation unfocused.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
} from "../../../shared/subagents-format.js";
|
||||
import {
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveCommandSurfaceChannel,
|
||||
resolveDiscordAccountId,
|
||||
@ -47,6 +48,7 @@ import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
export { extractAssistantText, stripToolMessages };
|
||||
export {
|
||||
isDiscordSurface,
|
||||
isMatrixSurface,
|
||||
isTelegramSurface,
|
||||
resolveCommandSurfaceChannel,
|
||||
resolveDiscordAccountId,
|
||||
|
||||
@ -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}.`;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
14
src/plugins/runtime/runtime-matrix.ts
Normal file
14
src/plugins/runtime/runtime-matrix.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user