mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 22:40:58 +00:00
matrix-js: add account-aware bindings and ACP routing
This commit is contained in:
@@ -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 ??
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
54
src/auto-reply/reply/matrix-context.ts
Normal file
54
src/auto-reply/reply/matrix-context.ts
Normal 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);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user