matrix-js: add account-aware bindings and ACP routing

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 18:43:15 -04:00
parent 565ff5f913
commit 96d7e4552d
32 changed files with 2194 additions and 367 deletions

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-js";
}
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
const channel =
params.ctx.OriginatingChannel ??

View File

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

View File

@@ -10,6 +10,10 @@ import { buildFeishuConversationId } from "../../../plugin-sdk/feishu.js";
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";
function parseFeishuTargetId(raw: unknown): string | undefined {
@@ -131,6 +135,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix-js") {
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: {
@@ -201,6 +217,18 @@ export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix-js") {
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

@@ -1,9 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
@@ -22,34 +19,24 @@ const hoisted = vi.hoisted(() => {
};
});
vi.mock("../../plugins/runtime/index.js", async () => {
const discordThreadBindings = await vi.importActual<
typeof import("../../../extensions/discord/src/monitor/thread-bindings.js")
>("../../../extensions/discord/src/monitor/thread-bindings.js");
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
return {
createPluginRuntime: () => ({
channel: {
discord: {
threadBindings: {
getManager: hoisted.getThreadBindingManagerMock,
resolveIdleTimeoutMs: discordThreadBindings.resolveThreadBindingIdleTimeoutMs,
resolveInactivityExpiresAt:
discordThreadBindings.resolveThreadBindingInactivityExpiresAt,
resolveMaxAgeMs: discordThreadBindings.resolveThreadBindingMaxAgeMs,
resolveMaxAgeExpiresAt: discordThreadBindings.resolveThreadBindingMaxAgeExpiresAt,
setIdleTimeoutBySessionKey: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: hoisted.setThreadBindingMaxAgeBySessionKeyMock,
unbindBySessionKey: vi.fn(),
},
},
telegram: {
threadBindings: {
setIdleTimeoutBySessionKey: hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
},
},
},
}),
...actual,
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKey: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKey: hoisted.setThreadBindingMaxAgeBySessionKeyMock,
};
});
vi.mock("../../telegram/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../telegram/thread-bindings.js")>();
return {
...actual,
setTelegramThreadBindingIdleTimeoutBySessionKey:
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKey:
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
};
});
@@ -60,9 +47,62 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal
...actual,
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
})),
listBySession: vi.fn(),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
setIdleTimeoutBySession: ({
channel,
targetSessionKey,
accountId,
idleTimeoutMs,
}: {
channel: string;
targetSessionKey: string;
accountId: string;
idleTimeoutMs: number;
}) =>
Promise.resolve(
channel === "telegram"
? hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock({
targetSessionKey,
accountId,
idleTimeoutMs,
})
: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock({
targetSessionKey,
accountId,
idleTimeoutMs,
}),
),
setMaxAgeBySession: ({
channel,
targetSessionKey,
accountId,
maxAgeMs,
}: {
channel: string;
targetSessionKey: string;
accountId: string;
maxAgeMs: number;
}) =>
Promise.resolve(
channel === "telegram"
? hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock({
targetSessionKey,
accountId,
maxAgeMs,
})
: hoisted.setThreadBindingMaxAgeBySessionKeyMock({
targetSessionKey,
accountId,
maxAgeMs,
}),
),
touch: vi.fn(),
unbind: vi.fn(),
}),
@@ -76,20 +116,6 @@ const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type FakeBinding = {
accountId: string;
channelId: string;
threadId: string;
targetKind: "subagent" | "acp";
targetSessionKey: string;
agentId: string;
boundBy: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
function createDiscordCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "discord",
@@ -114,18 +140,37 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
});
}
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
const now = Date.now();
function createMatrixCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
OriginatingTo: "room:!room:example",
To: "room:!room:example",
AccountId: "default",
MessageThreadId: "$thread-1",
...overrides,
});
}
function createDiscordBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
bindingId: "default:thread-1",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
boundBy: "user-1",
boundAt: now,
lastActivityAt: now,
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
@@ -152,34 +197,31 @@ function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): Sessi
};
}
function expectIdleTimeoutSetReply(
mock: ReturnType<typeof vi.fn>,
text: string,
idleTimeoutMs: number,
idleTimeoutLabel: string,
) {
expect(mock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs,
});
expect(text).toContain(`Idle timeout set to ${idleTimeoutLabel}`);
expect(text).toContain("2026-02-20T02:00:00.000Z");
}
function createFakeThreadBindingManager(binding: FakeBinding | null) {
function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
getByThreadId: vi.fn((_threadId: string) => binding),
getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000),
getMaxAgeMs: vi.fn(() => 0),
bindingId: "default:!room:example:$thread-1",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix-js",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
describe("/session idle and /session max-age", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
);
hoisted.getThreadBindingManagerMock.mockReset();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
@@ -193,11 +235,12 @@ describe("/session idle and /session max-age", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding();
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const binding = createDiscordBinding();
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(binding);
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
...binding,
targetSessionKey: binding.targetSessionKey,
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
@@ -206,23 +249,28 @@ describe("/session idle and /session max-age", () => {
const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("shows active idle timeout when no value is provided", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding({
idleTimeoutMs: 2 * 60 * 60 * 1000,
lastActivityAt: Date.now(),
const binding = createDiscordBinding({
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
maxAgeMs: 0,
},
});
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(binding);
const result = await handleSessionCommand(createDiscordCommandParams("/session idle"), true);
expect(result?.reply?.text).toContain("Idle timeout active (2h");
@@ -233,11 +281,11 @@ describe("/session idle and /session max-age", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding();
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const binding = createDiscordBinding();
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(binding);
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
...binding,
targetSessionKey: binding.targetSessionKey,
boundAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
@@ -278,12 +326,13 @@ describe("/session idle and /session max-age", () => {
);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
expect(hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("reports Telegram max-age expiry from the original bind time", async () => {
@@ -318,10 +367,49 @@ describe("/session idle and /session max-age", () => {
expect(text).toContain("2026-02-20T01:00:00.000Z");
});
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.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(createMatrixCommandParams("/session idle 2h"), true);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("disables max age when set to off", async () => {
const binding = createFakeBinding({ maxAgeMs: 2 * 60 * 60 * 1000 });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([{ ...binding, maxAgeMs: 0 }]);
const binding = createDiscordBinding({
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 2 * 60 * 60 * 1000,
},
});
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(binding);
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: binding.targetSessionKey,
boundAt: binding.boundAt,
maxAgeMs: 0,
},
]);
const result = await handleSessionCommand(
createDiscordCommandParams("/session max-age off"),
@@ -340,13 +428,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 binding owner for lifecycle updates", async () => {
const binding = createFakeBinding({ boundBy: "owner-1" });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const binding = createDiscordBinding({
metadata: {
boundBy: "owner-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
});
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(binding);
const result = await handleSessionCommand(
createDiscordCommandParams("/session idle 2h", {

View File

@@ -7,27 +7,29 @@ import { getSessionBindingService } from "../../infra/outbound/session-binding-s
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
import { createPluginRuntime } from "../../plugins/runtime/index.js";
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";
const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
const SESSION_ACTION_IDLE = "idle";
const SESSION_ACTION_MAX_AGE = "max-age";
let cachedChannelRuntime: ReturnType<typeof createPluginRuntime>["channel"] | undefined;
function getChannelRuntime() {
cachedChannelRuntime ??= createPluginRuntime().channel;
return cachedChannelRuntime;
}
function resolveSessionCommandUsage() {
return "Usage: /session idle <duration|off> | /session max-age <duration|off> (example: /session idle 24h)";
@@ -55,7 +57,7 @@ function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
}
function resolveTelegramBindingDurationMs(
function resolveBindingDurationMs(
binding: SessionBindingRecord,
key: "idleTimeoutMs" | "maxAgeMs",
fallbackMs: number,
@@ -67,7 +69,7 @@ function resolveTelegramBindingDurationMs(
return Math.max(0, Math.floor(raw));
}
function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number {
function resolveBindingLastActivityAt(binding: SessionBindingRecord): number {
const raw = binding.metadata?.lastActivityAt;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return binding.boundAt;
@@ -75,11 +77,37 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu
return Math.max(Math.floor(raw), binding.boundAt);
}
function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string {
function resolveBindingBoundBy(binding: SessionBindingRecord): string {
const raw = binding.metadata?.boundBy;
return typeof raw === "string" ? raw.trim() : "";
}
function resolveBindingConversationLabel(channel: string): "thread" | "conversation" {
return channel === "telegram" ? "conversation" : "thread";
}
function resolveIdleExpiresAt(
binding: SessionBindingRecord,
fallbackIdleTimeoutMs: number,
): number | undefined {
const idleTimeoutMs = resolveBindingDurationMs(binding, "idleTimeoutMs", fallbackIdleTimeoutMs);
if (idleTimeoutMs <= 0) {
return undefined;
}
return resolveBindingLastActivityAt(binding) + idleTimeoutMs;
}
function resolveMaxAgeExpiresAt(
binding: SessionBindingRecord,
fallbackMaxAgeMs: number,
): number | undefined {
const maxAgeMs = resolveBindingDurationMs(binding, "maxAgeMs", fallbackMaxAgeMs);
if (maxAgeMs <= 0) {
return undefined;
}
return binding.boundAt + maxAgeMs;
}
type UpdatedLifecycleBinding = {
boundAt: number;
lastActivityAt: number;
@@ -200,6 +228,57 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
};
};
export const handleFastCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/fast" && !normalized.startsWith("/fast ")) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /fast from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rawArgs = normalized === "/fast" ? "" : normalized.slice("/fast".length).trim();
const rawMode = rawArgs.toLowerCase();
if (!rawMode || rawMode === "status") {
const state = resolveFastModeState({
cfg: params.cfg,
provider: params.provider,
model: params.model,
sessionEntry: params.sessionEntry,
});
const suffix =
state.source === "config" ? " (config)" : state.source === "default" ? " (default)" : "";
return {
shouldContinue: false,
reply: { text: `⚙️ Current fast mode: ${state.enabled ? "on" : "off"}${suffix}.` },
};
}
const nextMode = normalizeFastMode(rawMode);
if (nextMode === undefined) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /fast status|on|off" },
};
}
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
params.sessionEntry.fastMode = nextMode;
await persistSessionEntry(params);
}
return {
shouldContinue: false,
reply: { text: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.` },
};
};
export const handleUsageCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
@@ -286,57 +365,6 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
};
};
export const handleFastCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/fast" && !normalized.startsWith("/fast ")) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /fast from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rawArgs = normalized === "/fast" ? "" : normalized.slice("/fast".length).trim();
const rawMode = rawArgs.toLowerCase();
if (!rawMode || rawMode === "status") {
const state = resolveFastModeState({
cfg: params.cfg,
provider: params.provider,
model: params.model,
sessionEntry: params.sessionEntry,
});
const suffix =
state.source === "config" ? " (config)" : state.source === "default" ? " (default)" : "";
return {
shouldContinue: false,
reply: { text: `⚙️ Current fast mode: ${state.enabled ? "on" : "off"}${suffix}.` },
};
}
const nextMode = normalizeFastMode(rawMode);
if (nextMode === undefined) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /fast status|on|off" },
};
}
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
params.sessionEntry.fastMode = nextMode;
await persistSessionEntry(params);
}
return {
shouldContinue: false,
reply: { text: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.` },
};
};
export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
@@ -363,44 +391,71 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
const onDiscord = isDiscordSurface(params);
const onMatrix = isMatrixSurface(params);
const onTelegram = isTelegramSurface(params);
if (!onDiscord && !onTelegram) {
if (!onDiscord && !onTelegram && !onMatrix) {
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.",
},
};
}
const accountId = resolveChannelAccountId(params);
const sessionBindingService = getSessionBindingService();
const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix-js";
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined;
const channelRuntime = getChannelRuntime();
const discordManager = onDiscord
? channelRuntime.discord.threadBindings.getManager(accountId)
: null;
if (onDiscord && !discordManager) {
const conversationId = onTelegram
? resolveTelegramConversationId(params)
: onMatrix
? resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: threadId || undefined;
const parentConversationId = onMatrix
? resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: undefined;
const capabilities = sessionBindingService.getCapabilities({ channel, accountId });
if (!capabilities.adapterAvailable) {
const label =
channel === "discord"
? "Discord thread"
: channel === "telegram"
? "Telegram conversation"
: "Matrix thread";
return {
shouldContinue: false,
reply: { text: "⚠️ Discord thread bindings are unavailable for this account." },
reply: { text: `⚠️ ${label} bindings are unavailable for this account.` },
};
}
const discordBinding =
onDiscord && threadId ? discordManager?.getByThreadId(threadId) : undefined;
const telegramBinding =
onTelegram && telegramConversationId
const binding =
conversationId != null
? sessionBindingService.resolveByConversation({
channel: "telegram",
channel,
accountId,
conversationId: telegramConversationId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
})
: null;
if (onDiscord && !discordBinding) {
if (!binding) {
if (onDiscord && !threadId) {
return {
shouldContinue: false,
@@ -409,13 +464,15 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
},
};
}
return {
shouldContinue: false,
reply: { text: " This thread is not currently focused." },
};
}
if (onTelegram && !telegramBinding) {
if (!telegramConversationId) {
if (onMatrix && !threadId) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.",
},
};
}
if (onTelegram && !conversationId) {
return {
shouldContinue: false,
reply: {
@@ -425,38 +482,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
return {
shouldContinue: false,
reply: { text: " This conversation is not currently focused." },
reply: {
text:
channel === "telegram"
? " This conversation is not currently focused."
: " This thread is not currently focused.",
},
};
}
const idleTimeoutMs = onDiscord
? channelRuntime.discord.threadBindings.resolveIdleTimeoutMs({
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000);
const idleExpiresAt = onDiscord
? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: idleTimeoutMs > 0
? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs
: undefined;
const maxAgeMs = onDiscord
? channelRuntime.discord.threadBindings.resolveMaxAgeMs({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0);
const maxAgeExpiresAt = onDiscord
? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: maxAgeMs > 0
? telegramBinding!.boundAt + maxAgeMs
: undefined;
const idleTimeoutMs = resolveBindingDurationMs(binding, "idleTimeoutMs", 24 * 60 * 60 * 1000);
const idleExpiresAt = resolveIdleExpiresAt(binding, 24 * 60 * 60 * 1000);
const maxAgeMs = resolveBindingDurationMs(binding, "maxAgeMs", 0);
const maxAgeExpiresAt = resolveMaxAgeExpiresAt(binding, 0);
const durationArgRaw = tokens.slice(1).join("");
if (!durationArgRaw) {
@@ -498,16 +536,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
const senderId = params.command.senderId?.trim() || "";
const boundBy = onDiscord
? discordBinding!.boundBy
: resolveTelegramBindingBoundBy(telegramBinding!);
const boundBy = resolveBindingBoundBy(binding);
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
const noun = resolveBindingConversationLabel(channel);
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.`,
text: `⚠️ Only ${boundBy} can update session lifecycle settings for this ${noun}.`,
},
};
}
@@ -522,32 +557,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
};
}
const updatedBindings = (() => {
if (onDiscord) {
return action === SESSION_ACTION_IDLE
? channelRuntime.discord.threadBindings.setIdleTimeoutBySessionKey({
targetSessionKey: discordBinding!.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: channelRuntime.discord.threadBindings.setMaxAgeBySessionKey({
targetSessionKey: discordBinding!.targetSessionKey,
accountId,
maxAgeMs: durationMs,
});
}
return action === SESSION_ACTION_IDLE
? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({
targetSessionKey: telegramBinding!.targetSessionKey,
const updatedBindings =
action === SESSION_ACTION_IDLE
? await sessionBindingService.setIdleTimeoutBySession({
channel,
accountId,
targetSessionKey: binding.targetSessionKey,
idleTimeoutMs: durationMs,
})
: channelRuntime.telegram.threadBindings.setMaxAgeBySessionKey({
targetSessionKey: telegramBinding!.targetSessionKey,
: await sessionBindingService.setMaxAgeBySession({
channel,
accountId,
targetSessionKey: binding.targetSessionKey,
maxAgeMs: durationMs,
});
})();
if (updatedBindings.length === 0) {
return {
shouldContinue: false,
@@ -574,7 +597,12 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const nextExpiry = resolveUpdatedBindingExpiry({
action,
bindings: updatedBindings,
bindings: updatedBindings.map((binding) => ({
boundAt: binding.boundAt,
lastActivityAt: resolveBindingLastActivityAt(binding),
idleTimeoutMs: resolveBindingDurationMs(binding, "idleTimeoutMs", 0),
maxAgeMs: resolveBindingDurationMs(binding, "maxAgeMs", 0),
})),
});
const expiryLabel =
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)

View File

@@ -29,6 +29,8 @@ const hoisted = vi.hoisted(() => {
function buildFocusSessionBindingService() {
return {
touch: vi.fn(),
setIdleTimeoutBySession: vi.fn(async () => []),
setMaxAgeBySession: vi.fn(async () => []),
listBySession(targetSessionKey: string) {
return hoisted.sessionBindingListBySessionMock(targetSessionKey);
},
@@ -103,6 +105,19 @@ function createTelegramTopicCommandParams(commandBody: string) {
return params;
}
function createMatrixCommandParams(commandBody: string) {
const params = buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
OriginatingTo: "room:!room:example",
To: "room:!room:example",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
function createSessionBindingRecord(
overrides?: Partial<SessionBindingRecord>,
): SessionBindingRecord {
@@ -220,6 +235,22 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/focus creates Matrix child thread bindings from top-level rooms", async () => {
const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp"));
expect(result?.reply?.text).toContain("created thread");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix-js",
conversationId: "!room:example",
parentConversationId: "!room:example",
}),
}),
);
});
it("/focus includes ACP session identifiers in intro text when available", async () => {
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
@@ -401,6 +432,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

@@ -15,6 +15,9 @@ function formatConversationBindingText(params: {
if (params.channel === "discord") {
return `thread:${params.conversationId}`;
}
if (params.channel === "matrix-js") {
return `thread:${params.conversationId}`;
}
if (params.channel === "telegram") {
return `conversation:${params.conversationId}`;
}
@@ -64,9 +67,9 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma
channel,
conversationId: binding.conversation.conversationId,
})
: channel === "discord" || channel === "telegram"
: channel === "discord" || channel === "telegram" || channel === "matrix-js"
? "unbound"
: "bindings available on discord/telegram";
: "bindings available on discord/matrix-js/telegram";
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
index += 1;
}

View File

@@ -16,19 +16,23 @@ import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
resolveDiscordChannelIdForFocus,
resolveFocusTargetSession,
resolveMatrixConversationId,
resolveMatrixParentConversationId,
resolveTelegramConversationId,
stopWithText,
} from "./shared.js";
type FocusBindingContext = {
channel: "discord" | "telegram";
channel: "discord" | "telegram" | "matrix-js";
accountId: string;
conversationId: string;
parentConversationId?: string;
placement: "current" | "child";
labelNoun: "thread" | "conversation";
};
@@ -65,6 +69,41 @@ function resolveFocusBindingContext(
labelNoun: "conversation",
};
}
if (isMatrixSurface(params)) {
const currentThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
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,
},
});
return {
channel: "matrix-js",
accountId: resolveChannelAccountId(params),
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
placement: currentThreadId ? "current" : "child",
labelNoun: "thread",
};
}
return null;
}
@@ -73,8 +112,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 !== "telegram" && channel !== "matrix-js") {
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
}
const token = restTokens.join(" ").trim();
@@ -89,7 +128,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 === "telegram"
? "Telegram conversation"
: "Matrix thread";
return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`);
}
@@ -105,6 +149,9 @@ export async function handleSubagentsFocusAction(
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
}
if (channel === "matrix-js") {
return stopWithText("⚠️ Could not resolve a Matrix conversation for /focus.");
}
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
}
@@ -113,6 +160,9 @@ export async function handleSubagentsFocusAction(
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
@@ -143,6 +193,9 @@ export async function handleSubagentsFocusAction(
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
},
placement: bindingContext.placement,
metadata: {

View File

@@ -3,9 +3,12 @@ import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
resolveMatrixConversationId,
resolveMatrixParentConversationId,
resolveTelegramConversationId,
stopWithText,
} from "./shared.js";
@@ -15,8 +18,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 !== "telegram" && channel !== "matrix-js") {
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
}
const accountId = resolveChannelAccountId(params);
@@ -27,16 +30,43 @@ export async function handleSubagentsUnfocusAction(
const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : "";
return threadId.trim() || undefined;
}
if (isMatrixSurface(params)) {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (isTelegramSurface(params)) {
return resolveTelegramConversationId(params);
}
return undefined;
})();
const parentConversationId = isMatrixSurface(params)
? resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: undefined;
if (!conversationId) {
if (channel === "discord") {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
}
if (channel === "matrix-js") {
return stopWithText("⚠️ /unfocus must be run inside a focused Matrix thread.");
}
return stopWithText(
"⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
@@ -46,12 +76,15 @@ export async function handleSubagentsUnfocusAction(
channel,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
});
if (!binding) {
return stopWithText(
channel === "discord"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
: channel === "matrix-js"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
);
}
@@ -62,7 +95,9 @@ export async function handleSubagentsUnfocusAction(
return stopWithText(
channel === "discord"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
: channel === "matrix-js"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
);
}
@@ -71,6 +106,8 @@ export async function handleSubagentsUnfocusAction(
reason: "manual",
});
return stopWithText(
channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.",
channel === "discord" || channel === "matrix-js"
? "✅ Thread unfocused."
: "✅ Conversation unfocused.",
);
}

View File

@@ -30,12 +30,17 @@ import {
} from "../../../shared/subagents-format.js";
import {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,
resolveChannelAccountId,
} from "../channel-context.js";
import type { CommandHandler, CommandHandlerResult } from "../commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.ts";
import {
formatRunLabel,
formatRunStatus,
@@ -47,10 +52,13 @@ import { resolveTelegramConversationId } from "../telegram-context.js";
export { extractAssistantText, stripToolMessages };
export {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,
resolveChannelAccountId,
resolveMatrixConversationId,
resolveMatrixParentConversationId,
resolveTelegramConversationId,
};

View File

@@ -0,0 +1,54 @@
type MatrixConversationParams = {
ctx: {
MessageThreadId?: string | number | null;
OriginatingTo?: string;
To?: string;
};
command: {
to?: string;
};
};
function normalizeMatrixTarget(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveMatrixRoomIdFromTarget(raw: string): string | undefined {
let target = normalizeMatrixTarget(raw);
if (!target) {
return undefined;
}
if (target.toLowerCase().startsWith("matrix:")) {
target = target.slice("matrix:".length).trim();
}
if (/^(room|channel):/i.test(target)) {
const roomId = target.replace(/^(room|channel):/i, "").trim();
return roomId || undefined;
}
if (target.startsWith("!") || target.startsWith("#")) {
return target;
}
return undefined;
}
export function resolveMatrixParentConversationId(
params: MatrixConversationParams,
): string | undefined {
const targets = [params.ctx.OriginatingTo, params.command.to, params.ctx.To];
for (const candidate of targets) {
const roomId = resolveMatrixRoomIdFromTarget(candidate ?? "");
if (roomId) {
return roomId;
}
}
return undefined;
}
export function resolveMatrixConversationId(params: MatrixConversationParams): string | undefined {
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
if (threadId) {
return threadId;
}
return resolveMatrixParentConversationId(params);
}

View File

@@ -467,7 +467,7 @@ export const FIELD_HELP: Record<string, string> = {
"bindings[].match":
"Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.",
"bindings[].match.channel":
"Channel/provider identifier this binding applies to, such as `telegram`, `discord`, or a plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.",
"Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix-js`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.",
"bindings[].match.accountId":
"Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.",
"bindings[].match.peer":
@@ -1598,6 +1598,16 @@ export const FIELD_HELP: Record<string, string> = {
"Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
"channels.discord.threadBindings.spawnAcpSessions":
"Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
"channels.matrix-js.threadBindings.enabled":
"Enable Matrix-js thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.",
"channels.matrix-js.threadBindings.idleHours":
"Inactivity window in hours for Matrix-js thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
"channels.matrix-js.threadBindings.maxAgeHours":
"Optional hard max age in hours for Matrix-js thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
"channels.matrix-js.threadBindings.spawnSubagentSessions":
"Allow subagent spawns/focus flows to auto-create and bind Matrix threads when starting from a top-level Matrix room or DM.",
"channels.matrix-js.threadBindings.spawnAcpSessions":
"Allow /acp spawn to auto-create and bind Matrix threads for ACP sessions when starting from a top-level Matrix room or DM.",
"channels.discord.ui.components.accentColor":
"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",
"channels.discord.voice.enabled":

View File

@@ -793,6 +793,12 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.threadBindings.maxAgeHours": "Discord Thread Binding Max Age (hours)",
"channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn",
"channels.discord.threadBindings.spawnAcpSessions": "Discord Thread-Bound ACP Spawn",
"channels.matrix-js.threadBindings.enabled": "Matrix-js Thread Binding Enabled",
"channels.matrix-js.threadBindings.idleHours": "Matrix-js Thread Binding Idle Timeout (hours)",
"channels.matrix-js.threadBindings.maxAgeHours": "Matrix-js Thread Binding Max Age (hours)",
"channels.matrix-js.threadBindings.spawnSubagentSessions":
"Matrix-js Thread-Bound Subagent Spawn",
"channels.matrix-js.threadBindings.spawnAcpSessions": "Matrix-js Thread-Bound ACP Spawn",
"channels.discord.ui.components.accentColor": "Discord Component Accent Color",
"channels.discord.intents.presence": "Discord Presence Intent",
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",

View File

@@ -71,12 +71,17 @@ const AcpBindingSchema = z
return;
}
const channel = value.match.channel.trim().toLowerCase();
if (channel !== "discord" && channel !== "telegram" && channel !== "feishu") {
if (
channel !== "discord" &&
channel !== "matrix-js" &&
channel !== "telegram" &&
channel !== "feishu"
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match", "channel"],
message:
'ACP bindings currently support only "discord", "telegram", and "feishu" channels.',
'ACP bindings currently support only "discord", "matrix-js", "telegram", and "feishu" channels.',
});
return;
}

View File

@@ -72,6 +72,18 @@ export type SessionBindingService = {
getCapabilities: (params: { channel: string; accountId: string }) => SessionBindingCapabilities;
listBySession: (targetSessionKey: string) => SessionBindingRecord[];
resolveByConversation: (ref: ConversationRef) => SessionBindingRecord | null;
setIdleTimeoutBySession: (params: {
channel: string;
accountId: string;
targetSessionKey: string;
idleTimeoutMs: number;
}) => Promise<SessionBindingRecord[]>;
setMaxAgeBySession: (params: {
channel: string;
accountId: string;
targetSessionKey: string;
maxAgeMs: number;
}) => Promise<SessionBindingRecord[]>;
touch: (bindingId: string, at?: number) => void;
unbind: (input: SessionBindingUnbindInput) => Promise<SessionBindingRecord[]>;
};
@@ -89,6 +101,14 @@ export type SessionBindingAdapter = {
bind?: (input: SessionBindingBindInput) => Promise<SessionBindingRecord | null>;
listBySession: (targetSessionKey: string) => SessionBindingRecord[];
resolveByConversation: (ref: ConversationRef) => SessionBindingRecord | null;
setIdleTimeoutBySession?: (params: {
targetSessionKey: string;
idleTimeoutMs: number;
}) => Promise<SessionBindingRecord[]> | SessionBindingRecord[];
setMaxAgeBySession?: (params: {
targetSessionKey: string;
maxAgeMs: number;
}) => Promise<SessionBindingRecord[]> | SessionBindingRecord[];
touch?: (bindingId: string, at?: number) => void;
unbind?: (input: SessionBindingUnbindInput) => Promise<SessionBindingRecord[]>;
};
@@ -291,6 +311,36 @@ function createDefaultSessionBindingService(): SessionBindingService {
}
return adapter.resolveByConversation(normalized);
},
setIdleTimeoutBySession: async (params) => {
const adapter = resolveAdapterForChannelAccount({
channel: params.channel,
accountId: params.accountId,
});
if (!adapter?.setIdleTimeoutBySession) {
return [];
}
return dedupeBindings(
await adapter.setIdleTimeoutBySession({
targetSessionKey: params.targetSessionKey.trim(),
idleTimeoutMs: Math.max(0, Math.floor(params.idleTimeoutMs)),
}),
);
},
setMaxAgeBySession: async (params) => {
const adapter = resolveAdapterForChannelAccount({
channel: params.channel,
accountId: params.accountId,
});
if (!adapter?.setMaxAgeBySession) {
return [];
}
return dedupeBindings(
await adapter.setMaxAgeBySession({
targetSessionKey: params.targetSessionKey.trim(),
maxAgeMs: Math.max(0, Math.floor(params.maxAgeMs)),
}),
);
},
touch: (bindingId, at) => {
const normalizedBindingId = bindingId.trim();
if (!normalizedBindingId) {

View File

@@ -56,6 +56,11 @@ export type {
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { ChannelSetupInput } from "../channels/plugins/types.js";
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
export {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "../channels/thread-bindings-policy.js";
export { createTypingCallbacks } from "../channels/typing.js";
export { resolveAckReaction } from "../agents/identity.js";
export type { OpenClawConfig } from "../config/config.js";
@@ -81,6 +86,16 @@ export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export {
getSessionBindingService,
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
} from "../infra/outbound/session-binding-service.js";
export type {
BindingTargetKind,
SessionBindingRecord,
SessionBindingAdapter,
} from "../infra/outbound/session-binding-service.js";
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
@@ -88,6 +103,9 @@ export type { OpenClawPluginApi } from "../plugins/types.js";
export type { PollInput } from "../polls.js";
export { normalizePollInput } from "../polls.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
export { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js";
export { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js";
export type { RuntimeEnv } from "../runtime.js";
export {
readStoreAllowFromForDmPolicy,