mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 08:21:39 +00:00
Revert "style(format): fix check drift in provider tests"
This reverts commit 2619f5fe55.
This commit is contained in:
@@ -10,8 +10,8 @@
|
||||
"exportName": "buildFalImageGenerationProvider",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 188,
|
||||
"path": "src/image-generation/providers/fal.ts"
|
||||
"line": 190,
|
||||
"path": "extensions/fal/image-generation-provider.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -19,8 +19,8 @@
|
||||
"exportName": "buildGoogleImageGenerationProvider",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 96,
|
||||
"path": "src/image-generation/providers/google.ts"
|
||||
"line": 98,
|
||||
"path": "extensions/google/image-generation-provider.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -28,8 +28,8 @@
|
||||
"exportName": "buildOpenAIImageGenerationProvider",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 42,
|
||||
"path": "src/image-generation/providers/openai.ts"
|
||||
"line": 43,
|
||||
"path": "extensions/openai/image-generation-provider.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{"category":"legacy","entrypoint":"index","importSpecifier":"openclaw/plugin-sdk","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/index.ts"}
|
||||
{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":188,"sourcePath":"src/image-generation/providers/fal.ts"}
|
||||
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":96,"sourcePath":"src/image-generation/providers/google.ts"}
|
||||
{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":42,"sourcePath":"src/image-generation/providers/openai.ts"}
|
||||
{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":190,"sourcePath":"extensions/fal/image-generation-provider.ts"}
|
||||
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":98,"sourcePath":"extensions/google/image-generation-provider.ts"}
|
||||
{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":43,"sourcePath":"extensions/openai/image-generation-provider.ts"}
|
||||
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"index","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
|
||||
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
|
||||
{"declaration":"export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => void): () => void;","entrypoint":"index","exportName":"onDiagnosticEvent","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":223,"sourcePath":"src/infra/diagnostic-events.ts"}
|
||||
|
||||
233
extensions/googlechat/src/monitor-access.test.ts
Normal file
233
extensions/googlechat/src/monitor-access.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createChannelPairingController = vi.hoisted(() => vi.fn());
|
||||
const evaluateGroupRouteAccessForPolicy = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn());
|
||||
const resolveMentionGatingWithBypass = vi.hoisted(() => vi.fn());
|
||||
const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
GROUP_POLICY_BLOCKED_LABEL: { space: "space" },
|
||||
createChannelPairingController,
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveMentionGatingWithBypass,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendGoogleChatMessage,
|
||||
}));
|
||||
|
||||
function createCore() {
|
||||
return {
|
||||
channel: {
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
shouldHandleTextCommands: vi.fn(() => false),
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function primeCommonDefaults() {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveDefaultGroupPolicy.mockReturnValue("allowlist");
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
resolveSenderScopedGroupPolicy.mockImplementation(({ groupPolicy }) => groupPolicy);
|
||||
evaluateGroupRouteAccessForPolicy.mockReturnValue({
|
||||
allowed: true,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined);
|
||||
}
|
||||
|
||||
describe("googlechat inbound access policy", () => {
|
||||
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
|
||||
primeCommonDefaults();
|
||||
const issueChallenge = vi.fn(async ({ onCreated, sendPairingReply }) => {
|
||||
onCreated?.();
|
||||
await sendPairingReply("pairing text");
|
||||
});
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge,
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "pairing",
|
||||
reason: "pairing_required",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({ ok: true });
|
||||
|
||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
||||
const statusSink = vi.fn();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyGoogleChatInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: { policy: "pairing" },
|
||||
},
|
||||
} as never,
|
||||
config: {
|
||||
channels: { googlechat: {} },
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/AAA", displayName: "DM" } as never,
|
||||
message: { annotations: [] } as never,
|
||||
isGroup: false,
|
||||
senderId: "users/abc",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
rawBody: "hello",
|
||||
statusSink,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(issueChallenge).toHaveBeenCalledTimes(1);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account: expect.anything(),
|
||||
space: "spaces/AAA",
|
||||
text: "pairing text",
|
||||
});
|
||||
expect(statusSink).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastOutboundAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows group traffic when sender and mention gates pass", async () => {
|
||||
primeCommonDefaults();
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "allow",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: ["users/alice"],
|
||||
});
|
||||
resolveMentionGatingWithBypass.mockReturnValue({
|
||||
shouldSkip: false,
|
||||
effectiveWasMentioned: true,
|
||||
});
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
||||
|
||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
||||
|
||||
await expect(
|
||||
applyGoogleChatInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
botUser: "users/app-bot",
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["users/alice"],
|
||||
requireMention: true,
|
||||
systemPrompt: " group prompt ",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
config: {
|
||||
channels: { googlechat: {} },
|
||||
commands: { useAccessGroups: true },
|
||||
} as never,
|
||||
core: core as never,
|
||||
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
|
||||
message: {
|
||||
annotations: [
|
||||
{
|
||||
type: "USER_MENTION",
|
||||
userMention: { user: { name: "users/app-bot" } },
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
isGroup: true,
|
||||
senderId: "users/alice",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
rawBody: "hello team",
|
||||
logVerbose: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
effectiveWasMentioned: true,
|
||||
groupSystemPrompt: "group prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops unauthorized group control commands", async () => {
|
||||
primeCommonDefaults();
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "allow",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
resolveMentionGatingWithBypass.mockReturnValue({
|
||||
shouldSkip: false,
|
||||
effectiveWasMentioned: false,
|
||||
});
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
||||
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
||||
|
||||
await expect(
|
||||
applyGoogleChatInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as never,
|
||||
config: {
|
||||
channels: { googlechat: {} },
|
||||
commands: { useAccessGroups: true },
|
||||
} as never,
|
||||
core: core as never,
|
||||
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
|
||||
message: { annotations: [] } as never,
|
||||
isGroup: true,
|
||||
senderId: "users/alice",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
rawBody: "/admin",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith("googlechat: drop control command from users/alice");
|
||||
});
|
||||
});
|
||||
151
extensions/mattermost/src/mattermost/monitor-auth.test.ts
Normal file
151
extensions/mattermost/src/mattermost/monitor-auth.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
|
||||
const resolveControlCommandGate = vi.hoisted(() => vi.fn());
|
||||
const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
it("normalizes allowlist entries and resolves effective lists", async () => {
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: ["alice"],
|
||||
effectiveGroupAllowFrom: ["team"],
|
||||
});
|
||||
|
||||
const {
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
resolveMattermostEffectiveAllowFromLists,
|
||||
} = await import("./monitor-auth.js");
|
||||
|
||||
expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice");
|
||||
expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob");
|
||||
expect(normalizeMattermostAllowEntry("*")).toBe("*");
|
||||
expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([
|
||||
"alice",
|
||||
"*",
|
||||
]);
|
||||
expect(
|
||||
resolveMattermostEffectiveAllowFromLists({
|
||||
allowFrom: [" Alice "],
|
||||
groupAllowFrom: [" Team "],
|
||||
storeAllowFrom: ["Store"],
|
||||
dmPolicy: "pairing",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveAllowFrom: ["alice"],
|
||||
effectiveGroupAllowFrom: ["team"],
|
||||
});
|
||||
expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({
|
||||
allowFrom: ["alice"],
|
||||
groupAllowFrom: ["team"],
|
||||
storeAllowFrom: ["store"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks sender allowlists against normalized ids and names", async () => {
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
|
||||
|
||||
const { isMattermostSenderAllowed } = await import("./monitor-auth.js");
|
||||
expect(
|
||||
isMattermostSenderAllowed({
|
||||
senderId: "@Alice",
|
||||
senderName: "Alice",
|
||||
allowFrom: [" mattermost:alice "],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(resolveAllowlistMatchSimple).toHaveBeenCalledWith({
|
||||
allowFrom: ["alice"],
|
||||
senderId: "alice",
|
||||
senderName: "alice",
|
||||
allowNameMatching: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes direct messages in open mode and blocks disabled/group-restricted channels", async () => {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
resolveControlCommandGate.mockReturnValue({
|
||||
commandAuthorized: false,
|
||||
shouldBlock: false,
|
||||
});
|
||||
evaluateSenderGroupAccessForPolicy.mockReturnValue({
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
});
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
|
||||
|
||||
const { authorizeMattermostCommandInvocation } = await import("./monitor-auth.js");
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "open" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
kind: "direct",
|
||||
roomLabel: "#alice",
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "disabled" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
denyReason: "dm-disabled",
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { groupPolicy: "allowlist" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "chan-1",
|
||||
channelInfo: { type: "O", name: "town-square", display_name: "Town Square" } as never,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
denyReason: "channel-no-allowlist",
|
||||
kind: "channel",
|
||||
});
|
||||
});
|
||||
});
|
||||
84
extensions/mattermost/src/mattermost/monitor-gating.test.ts
Normal file
84
extensions/mattermost/src/mattermost/monitor-gating.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
evaluateMattermostMentionGate,
|
||||
mapMattermostChannelTypeToChatType,
|
||||
} from "./monitor-gating.js";
|
||||
|
||||
describe("mattermost monitor gating", () => {
|
||||
it("maps mattermost channel types to chat types", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("G")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
|
||||
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel");
|
||||
});
|
||||
|
||||
it("drops non-mentioned traffic when onchar is enabled but not triggered", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: true,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
dropReason: "onchar-not-triggered",
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses mention for authorized control commands and allows direct chats", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: true,
|
||||
commandAuthorized: true,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
dropReason: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "direct",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
shouldRequireMention: false,
|
||||
dropReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,9 @@ describe("nextcloud talk setup core", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toMatchObject({
|
||||
expect(
|
||||
clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
@@ -70,19 +72,18 @@ describe("nextcloud talk setup core", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"]),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
work: {
|
||||
apiPassword: "api-secret",
|
||||
expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"]))
|
||||
.toMatchObject({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
work: {
|
||||
apiPassword: "api-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("sets top-level DM policy state", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
|
||||
import { buildOpenAIProvider } from "./openai-provider.js";
|
||||
import { buildOpenAISpeechProvider } from "./speech-provider.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
@@ -13,13 +14,16 @@ import {
|
||||
resolveOriginMessageTo,
|
||||
} from "./origin-routing.js";
|
||||
import { normalizeReplyPayloadDirectives } from "./reply-delivery.js";
|
||||
import {
|
||||
applyReplyThreading,
|
||||
filterMessagingToolDuplicates,
|
||||
filterMessagingToolMediaDuplicates,
|
||||
isRenderablePayload,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
import { applyReplyThreading, isRenderablePayload } from "./reply-payloads-base.js";
|
||||
|
||||
let replyPayloadsDedupeRuntimePromise: Promise<
|
||||
typeof import("./reply-payloads-dedupe.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
function loadReplyPayloadsDedupeRuntime() {
|
||||
replyPayloadsDedupeRuntimePromise ??= import("./reply-payloads-dedupe.runtime.js");
|
||||
return replyPayloadsDedupeRuntimePromise;
|
||||
}
|
||||
|
||||
async function normalizeReplyPayloadMedia(params: {
|
||||
payload: ReplyPayload;
|
||||
@@ -97,9 +101,7 @@ export async function buildReplyPayloads(params: {
|
||||
messageProvider?: string;
|
||||
messagingToolSentTexts?: string[];
|
||||
messagingToolSentMediaUrls?: string[];
|
||||
messagingToolSentTargets?: Parameters<
|
||||
typeof shouldSuppressMessagingToolReplies
|
||||
>[0]["messagingToolSentTargets"];
|
||||
messagingToolSentTargets?: MessagingToolSend[];
|
||||
originatingChannel?: OriginatingChannelType;
|
||||
originatingTo?: string;
|
||||
accountId?: string;
|
||||
@@ -160,19 +162,27 @@ export async function buildReplyPayloads(params: {
|
||||
!params.blockReplyPipeline?.isAborted();
|
||||
const messagingToolSentTexts = params.messagingToolSentTexts ?? [];
|
||||
const messagingToolSentTargets = params.messagingToolSentTargets ?? [];
|
||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||
messageProvider: resolveOriginMessageProvider({
|
||||
originatingChannel: params.originatingChannel,
|
||||
provider: params.messageProvider,
|
||||
}),
|
||||
messagingToolSentTargets,
|
||||
originatingTo: resolveOriginMessageTo({
|
||||
originatingTo: params.originatingTo,
|
||||
}),
|
||||
accountId: resolveOriginAccountId({
|
||||
originatingAccountId: params.accountId,
|
||||
}),
|
||||
});
|
||||
const shouldCheckMessagingToolDedupe =
|
||||
messagingToolSentTexts.length > 0 ||
|
||||
(params.messagingToolSentMediaUrls?.length ?? 0) > 0 ||
|
||||
messagingToolSentTargets.length > 0;
|
||||
const dedupeRuntime = shouldCheckMessagingToolDedupe
|
||||
? await loadReplyPayloadsDedupeRuntime()
|
||||
: null;
|
||||
const suppressMessagingToolReplies =
|
||||
dedupeRuntime?.shouldSuppressMessagingToolReplies({
|
||||
messageProvider: resolveOriginMessageProvider({
|
||||
originatingChannel: params.originatingChannel,
|
||||
provider: params.messageProvider,
|
||||
}),
|
||||
messagingToolSentTargets,
|
||||
originatingTo: resolveOriginMessageTo({
|
||||
originatingTo: params.originatingTo,
|
||||
}),
|
||||
accountId: resolveOriginAccountId({
|
||||
originatingAccountId: params.accountId,
|
||||
}),
|
||||
}) ?? false;
|
||||
// Only dedupe against messaging tool sends for the same origin target.
|
||||
// Cross-target sends (for example posting to another channel) must not
|
||||
// suppress the current conversation's final reply.
|
||||
@@ -186,13 +196,15 @@ export async function buildReplyPayloads(params: {
|
||||
})
|
||||
: (params.messagingToolSentMediaUrls ?? []);
|
||||
const dedupedPayloads = dedupeMessagingToolPayloads
|
||||
? filterMessagingToolDuplicates({
|
||||
? (dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())).filterMessagingToolDuplicates({
|
||||
payloads: replyTaggedPayloads,
|
||||
sentTexts: messagingToolSentTexts,
|
||||
})
|
||||
: replyTaggedPayloads;
|
||||
const mediaFilteredPayloads = dedupeMessagingToolPayloads
|
||||
? filterMessagingToolMediaDuplicates({
|
||||
? (
|
||||
dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())
|
||||
).filterMessagingToolMediaDuplicates({
|
||||
payloads: dedupedPayloads,
|
||||
sentMediaUrls: messagingToolSentMediaUrls,
|
||||
})
|
||||
|
||||
88
src/auto-reply/reply/reply-payloads-base.ts
Normal file
88
src/auto-reply/reply/reply-payloads-base.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
||||
|
||||
export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined {
|
||||
const text = payload.text?.trim();
|
||||
if (!text) {
|
||||
return payload.text;
|
||||
}
|
||||
const question = payload.btw?.question?.trim();
|
||||
if (!question) {
|
||||
return payload.text;
|
||||
}
|
||||
const formatted = `BTW\nQuestion: ${question}\n\n${text}`;
|
||||
return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted;
|
||||
}
|
||||
|
||||
function resolveReplyThreadingForPayload(params: {
|
||||
payload: ReplyPayload;
|
||||
implicitReplyToId?: string;
|
||||
currentMessageId?: string;
|
||||
}): ReplyPayload {
|
||||
const implicitReplyToId = params.implicitReplyToId?.trim() || undefined;
|
||||
const currentMessageId = params.currentMessageId?.trim() || undefined;
|
||||
|
||||
let resolved: ReplyPayload =
|
||||
params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId
|
||||
? params.payload
|
||||
: { ...params.payload, replyToId: implicitReplyToId };
|
||||
|
||||
if (typeof resolved.text === "string" && resolved.text.includes("[[")) {
|
||||
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
|
||||
resolved.text,
|
||||
currentMessageId,
|
||||
);
|
||||
resolved = {
|
||||
...resolved,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? resolved.replyToId,
|
||||
replyToTag: hasTag || resolved.replyToTag,
|
||||
replyToCurrent: replyToCurrent || resolved.replyToCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) {
|
||||
resolved = {
|
||||
...resolved,
|
||||
replyToId: currentMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function applyReplyTagsToPayload(
|
||||
payload: ReplyPayload,
|
||||
currentMessageId?: string,
|
||||
): ReplyPayload {
|
||||
return resolveReplyThreadingForPayload({ payload, currentMessageId });
|
||||
}
|
||||
|
||||
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice });
|
||||
}
|
||||
|
||||
export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean {
|
||||
return payload.isReasoning === true;
|
||||
}
|
||||
|
||||
export function applyReplyThreading(params: {
|
||||
payloads: ReplyPayload[];
|
||||
replyToMode: ReplyToMode;
|
||||
replyToChannel?: OriginatingChannelType;
|
||||
currentMessageId?: string;
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
const implicitReplyToId = currentMessageId?.trim() || undefined;
|
||||
return payloads
|
||||
.map((payload) =>
|
||||
resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }),
|
||||
)
|
||||
.filter(isRenderablePayload)
|
||||
.map(applyReplyToMode);
|
||||
}
|
||||
5
src/auto-reply/reply/reply-payloads-dedupe.runtime.ts
Normal file
5
src/auto-reply/reply/reply-payloads-dedupe.runtime.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
filterMessagingToolDuplicates,
|
||||
filterMessagingToolMediaDuplicates,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads-dedupe.js";
|
||||
179
src/auto-reply/reply/reply-payloads-dedupe.ts
Normal file
179
src/auto-reply/reply/reply-payloads-dedupe.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
|
||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
export function filterMessagingToolDuplicates(params: {
|
||||
payloads: ReplyPayload[];
|
||||
sentTexts: string[];
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, sentTexts } = params;
|
||||
if (sentTexts.length === 0) {
|
||||
return payloads;
|
||||
}
|
||||
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
|
||||
}
|
||||
|
||||
export function filterMessagingToolMediaDuplicates(params: {
|
||||
payloads: ReplyPayload[];
|
||||
sentMediaUrls: string[];
|
||||
}): ReplyPayload[] {
|
||||
const normalizeMediaForDedupe = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (!trimmed.toLowerCase().startsWith("file://")) {
|
||||
return trimmed;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "file:") {
|
||||
return decodeURIComponent(parsed.pathname || "");
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback below for non-URL-like inputs.
|
||||
}
|
||||
return trimmed.replace(/^file:\/\//i, "");
|
||||
};
|
||||
|
||||
const { payloads, sentMediaUrls } = params;
|
||||
if (sentMediaUrls.length === 0) {
|
||||
return payloads;
|
||||
}
|
||||
const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean));
|
||||
return payloads.map((payload) => {
|
||||
const mediaUrl = payload.mediaUrl;
|
||||
const mediaUrls = payload.mediaUrls;
|
||||
const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl));
|
||||
const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u)));
|
||||
if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
mediaUrl: stripSingle ? undefined : mediaUrl,
|
||||
mediaUrls: filteredUrls?.length ? filteredUrls : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const PROVIDER_ALIAS_MAP: Record<string, string> = {
|
||||
lark: "feishu",
|
||||
};
|
||||
|
||||
function normalizeProviderForComparison(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const normalizedChannel = normalizeChannelId(trimmed);
|
||||
if (normalizedChannel) {
|
||||
return normalizedChannel;
|
||||
}
|
||||
return PROVIDER_ALIAS_MAP[lowered] ?? lowered;
|
||||
}
|
||||
|
||||
function normalizeThreadIdForComparison(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
return String(Number.parseInt(trimmed, 10));
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveTargetProviderForComparison(params: {
|
||||
currentProvider: string;
|
||||
targetProvider?: string;
|
||||
}): string {
|
||||
const targetProvider = normalizeProviderForComparison(params.targetProvider);
|
||||
if (!targetProvider || targetProvider === "message") {
|
||||
return params.currentProvider;
|
||||
}
|
||||
return targetProvider;
|
||||
}
|
||||
|
||||
function targetsMatchForSuppression(params: {
|
||||
provider: string;
|
||||
originTarget: string;
|
||||
targetKey: string;
|
||||
targetThreadId?: string;
|
||||
}): boolean {
|
||||
if (params.provider !== "telegram") {
|
||||
return params.targetKey === params.originTarget;
|
||||
}
|
||||
|
||||
const origin = parseExplicitTargetForChannel("telegram", params.originTarget);
|
||||
const target = parseExplicitTargetForChannel("telegram", params.targetKey);
|
||||
if (!origin || !target) {
|
||||
return params.targetKey === params.originTarget;
|
||||
}
|
||||
const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId);
|
||||
const targetThreadId =
|
||||
explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined);
|
||||
const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined;
|
||||
if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
if (originThreadId && targetThreadId != null) {
|
||||
return originThreadId === targetThreadId;
|
||||
}
|
||||
if (originThreadId && targetThreadId == null) {
|
||||
return false;
|
||||
}
|
||||
if (!originThreadId && targetThreadId != null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldSuppressMessagingToolReplies(params: {
|
||||
messageProvider?: string;
|
||||
messagingToolSentTargets?: MessagingToolSend[];
|
||||
originatingTo?: string;
|
||||
accountId?: string;
|
||||
}): boolean {
|
||||
const provider = normalizeProviderForComparison(params.messageProvider);
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
|
||||
if (!originTarget) {
|
||||
return false;
|
||||
}
|
||||
const originAccount = normalizeOptionalAccountId(params.accountId);
|
||||
const sentTargets = params.messagingToolSentTargets ?? [];
|
||||
if (sentTargets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return sentTargets.some((target) => {
|
||||
const targetProvider = resolveTargetProviderForComparison({
|
||||
currentProvider: provider,
|
||||
targetProvider: target?.provider,
|
||||
});
|
||||
if (targetProvider !== provider) {
|
||||
return false;
|
||||
}
|
||||
const targetKey = normalizeTargetForProvider(targetProvider, target.to);
|
||||
if (!targetKey) {
|
||||
return false;
|
||||
}
|
||||
const targetAccount = normalizeOptionalAccountId(target.accountId);
|
||||
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
||||
return false;
|
||||
}
|
||||
return targetsMatchForSuppression({
|
||||
provider,
|
||||
originTarget,
|
||||
targetKey,
|
||||
targetThreadId: target.threadId,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export { applyReplyThreading } from "./reply-payloads-base.js";
|
||||
export {
|
||||
applyReplyThreading,
|
||||
filterMessagingToolDuplicates,
|
||||
filterMessagingToolMediaDuplicates,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
} from "./reply-payloads-dedupe.js";
|
||||
|
||||
@@ -1,273 +1,12 @@
|
||||
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
||||
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
||||
|
||||
export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined {
|
||||
const text = payload.text?.trim();
|
||||
if (!text) {
|
||||
return payload.text;
|
||||
}
|
||||
const question = payload.btw?.question?.trim();
|
||||
if (!question) {
|
||||
return payload.text;
|
||||
}
|
||||
const formatted = `BTW\nQuestion: ${question}\n\n${text}`;
|
||||
return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted;
|
||||
}
|
||||
|
||||
function resolveReplyThreadingForPayload(params: {
|
||||
payload: ReplyPayload;
|
||||
implicitReplyToId?: string;
|
||||
currentMessageId?: string;
|
||||
}): ReplyPayload {
|
||||
const implicitReplyToId = params.implicitReplyToId?.trim() || undefined;
|
||||
const currentMessageId = params.currentMessageId?.trim() || undefined;
|
||||
|
||||
// 1) Apply implicit reply threading first (replyToMode will strip later if needed).
|
||||
let resolved: ReplyPayload =
|
||||
params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId
|
||||
? params.payload
|
||||
: { ...params.payload, replyToId: implicitReplyToId };
|
||||
|
||||
// 2) Parse explicit reply tags from text (if present) and clean them.
|
||||
if (typeof resolved.text === "string" && resolved.text.includes("[[")) {
|
||||
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
|
||||
resolved.text,
|
||||
currentMessageId,
|
||||
);
|
||||
resolved = {
|
||||
...resolved,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? resolved.replyToId,
|
||||
replyToTag: hasTag || resolved.replyToTag,
|
||||
replyToCurrent: replyToCurrent || resolved.replyToCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) If replyToCurrent was set out-of-band (e.g. tags already stripped upstream),
|
||||
// ensure replyToId is set to the current message id when available.
|
||||
if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) {
|
||||
resolved = {
|
||||
...resolved,
|
||||
replyToId: currentMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Backward-compatible helper: apply explicit reply tags/directives to a single payload.
|
||||
// This intentionally does not apply implicit threading.
|
||||
export function applyReplyTagsToPayload(
|
||||
payload: ReplyPayload,
|
||||
currentMessageId?: string,
|
||||
): ReplyPayload {
|
||||
return resolveReplyThreadingForPayload({ payload, currentMessageId });
|
||||
}
|
||||
|
||||
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice });
|
||||
}
|
||||
|
||||
export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean {
|
||||
return payload.isReasoning === true;
|
||||
}
|
||||
|
||||
export function applyReplyThreading(params: {
|
||||
payloads: ReplyPayload[];
|
||||
replyToMode: ReplyToMode;
|
||||
replyToChannel?: OriginatingChannelType;
|
||||
currentMessageId?: string;
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
const implicitReplyToId = currentMessageId?.trim() || undefined;
|
||||
return payloads
|
||||
.map((payload) =>
|
||||
resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }),
|
||||
)
|
||||
.filter(isRenderablePayload)
|
||||
.map(applyReplyToMode);
|
||||
}
|
||||
|
||||
export function filterMessagingToolDuplicates(params: {
|
||||
payloads: ReplyPayload[];
|
||||
sentTexts: string[];
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, sentTexts } = params;
|
||||
if (sentTexts.length === 0) {
|
||||
return payloads;
|
||||
}
|
||||
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
|
||||
}
|
||||
|
||||
export function filterMessagingToolMediaDuplicates(params: {
|
||||
payloads: ReplyPayload[];
|
||||
sentMediaUrls: string[];
|
||||
}): ReplyPayload[] {
|
||||
const normalizeMediaForDedupe = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (!trimmed.toLowerCase().startsWith("file://")) {
|
||||
return trimmed;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "file:") {
|
||||
return decodeURIComponent(parsed.pathname || "");
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback below for non-URL-like inputs.
|
||||
}
|
||||
return trimmed.replace(/^file:\/\//i, "");
|
||||
};
|
||||
|
||||
const { payloads, sentMediaUrls } = params;
|
||||
if (sentMediaUrls.length === 0) {
|
||||
return payloads;
|
||||
}
|
||||
const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean));
|
||||
return payloads.map((payload) => {
|
||||
const mediaUrl = payload.mediaUrl;
|
||||
const mediaUrls = payload.mediaUrls;
|
||||
const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl));
|
||||
const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u)));
|
||||
if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) {
|
||||
return payload; // No change
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
mediaUrl: stripSingle ? undefined : mediaUrl,
|
||||
mediaUrls: filteredUrls?.length ? filteredUrls : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const PROVIDER_ALIAS_MAP: Record<string, string> = {
|
||||
lark: "feishu",
|
||||
};
|
||||
|
||||
function normalizeProviderForComparison(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const normalizedChannel = normalizeChannelId(trimmed);
|
||||
if (normalizedChannel) {
|
||||
return normalizedChannel;
|
||||
}
|
||||
return PROVIDER_ALIAS_MAP[lowered] ?? lowered;
|
||||
}
|
||||
|
||||
function normalizeThreadIdForComparison(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
return String(Number.parseInt(trimmed, 10));
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveTargetProviderForComparison(params: {
|
||||
currentProvider: string;
|
||||
targetProvider?: string;
|
||||
}): string {
|
||||
const targetProvider = normalizeProviderForComparison(params.targetProvider);
|
||||
if (!targetProvider || targetProvider === "message") {
|
||||
return params.currentProvider;
|
||||
}
|
||||
return targetProvider;
|
||||
}
|
||||
|
||||
function targetsMatchForSuppression(params: {
|
||||
provider: string;
|
||||
originTarget: string;
|
||||
targetKey: string;
|
||||
targetThreadId?: string;
|
||||
}): boolean {
|
||||
if (params.provider !== "telegram") {
|
||||
return params.targetKey === params.originTarget;
|
||||
}
|
||||
|
||||
const origin = parseExplicitTargetForChannel("telegram", params.originTarget);
|
||||
const target = parseExplicitTargetForChannel("telegram", params.targetKey);
|
||||
if (!origin || !target) {
|
||||
return params.targetKey === params.originTarget;
|
||||
}
|
||||
const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId);
|
||||
const targetThreadId =
|
||||
explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined);
|
||||
const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined;
|
||||
if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
if (originThreadId && targetThreadId != null) {
|
||||
return originThreadId === targetThreadId;
|
||||
}
|
||||
if (originThreadId && targetThreadId == null) {
|
||||
return false;
|
||||
}
|
||||
if (!originThreadId && targetThreadId != null) {
|
||||
return false;
|
||||
}
|
||||
// chatId already matched and neither side carries thread context.
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldSuppressMessagingToolReplies(params: {
|
||||
messageProvider?: string;
|
||||
messagingToolSentTargets?: MessagingToolSend[];
|
||||
originatingTo?: string;
|
||||
accountId?: string;
|
||||
}): boolean {
|
||||
const provider = normalizeProviderForComparison(params.messageProvider);
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
|
||||
if (!originTarget) {
|
||||
return false;
|
||||
}
|
||||
const originAccount = normalizeOptionalAccountId(params.accountId);
|
||||
const sentTargets = params.messagingToolSentTargets ?? [];
|
||||
if (sentTargets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return sentTargets.some((target) => {
|
||||
const targetProvider = resolveTargetProviderForComparison({
|
||||
currentProvider: provider,
|
||||
targetProvider: target?.provider,
|
||||
});
|
||||
if (targetProvider !== provider) {
|
||||
return false;
|
||||
}
|
||||
const targetKey = normalizeTargetForProvider(targetProvider, target.to);
|
||||
if (!targetKey) {
|
||||
return false;
|
||||
}
|
||||
const targetAccount = normalizeOptionalAccountId(target.accountId);
|
||||
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
||||
return false;
|
||||
}
|
||||
return targetsMatchForSuppression({
|
||||
provider,
|
||||
originTarget,
|
||||
targetKey,
|
||||
targetThreadId: target.threadId,
|
||||
});
|
||||
});
|
||||
}
|
||||
export {
|
||||
applyReplyTagsToPayload,
|
||||
applyReplyThreading,
|
||||
formatBtwTextForExternalDelivery,
|
||||
isRenderablePayload,
|
||||
shouldSuppressReasoningPayload,
|
||||
} from "./reply-payloads-base.js";
|
||||
export {
|
||||
filterMessagingToolDuplicates,
|
||||
filterMessagingToolMediaDuplicates,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads-dedupe.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildFalImageGenerationProvider } from "../../../extensions/fal/image-generation-provider.js";
|
||||
import * as modelAuth from "../../agents/model-auth.js";
|
||||
import { buildFalImageGenerationProvider } from "../../../extensions/fal/image-generation-provider.js";
|
||||
|
||||
function expectFalJsonPost(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleImageGenerationProvider } from "../../../extensions/google/image-generation-provider.js";
|
||||
import * as modelAuth from "../../agents/model-auth.js";
|
||||
import { buildGoogleImageGenerationProvider } from "../../../extensions/google/image-generation-provider.js";
|
||||
|
||||
describe("Google image-generation provider", () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenAIImageGenerationProvider } from "../../../extensions/openai/image-generation-provider.js";
|
||||
import * as modelAuth from "../../agents/model-auth.js";
|
||||
import { buildOpenAIImageGenerationProvider } from "../../../extensions/openai/image-generation-provider.js";
|
||||
|
||||
describe("OpenAI image-generation provider", () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -165,10 +165,8 @@ describe("applyMediaUnderstanding – echo transcript", () => {
|
||||
}));
|
||||
vi.doMock("./providers/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./providers/index.js")>();
|
||||
const { deepgramProvider } =
|
||||
await import("../../extensions/deepgram/media-understanding-provider.js");
|
||||
const { groqProvider } =
|
||||
await import("../../extensions/groq/media-understanding-provider.js");
|
||||
const { deepgramProvider } = await import("../../extensions/deepgram/media-understanding-provider.js");
|
||||
const { groqProvider } = await import("../../extensions/groq/media-understanding-provider.js");
|
||||
return {
|
||||
...actual,
|
||||
buildMediaUnderstandingRegistry: (
|
||||
|
||||
@@ -248,10 +248,8 @@ describe("applyMediaUnderstanding", () => {
|
||||
}));
|
||||
vi.doMock("./providers/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./providers/index.js")>();
|
||||
const { deepgramProvider } =
|
||||
await import("../../extensions/deepgram/media-understanding-provider.js");
|
||||
const { groqProvider } =
|
||||
await import("../../extensions/groq/media-understanding-provider.js");
|
||||
const { deepgramProvider } = await import("../../extensions/deepgram/media-understanding-provider.js");
|
||||
const { groqProvider } = await import("../../extensions/groq/media-understanding-provider.js");
|
||||
return {
|
||||
...actual,
|
||||
buildMediaUnderstandingRegistry: (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js";
|
||||
import { isTruthyEnvValue } from "../../../infra/env.js";
|
||||
import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js";
|
||||
|
||||
const DEEPGRAM_KEY = process.env.DEEPGRAM_API_KEY ?? "";
|
||||
const DEEPGRAM_MODEL = process.env.DEEPGRAM_MODEL?.trim() || "nova-3";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js";
|
||||
import {
|
||||
createAuthCaptureJsonFetch,
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "../audio.test-helpers.js";
|
||||
import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
|
||||
@@ -468,6 +468,28 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dirName: "deepgram",
|
||||
idHint: "deepgram-media-understanding",
|
||||
source: {
|
||||
source: "./index.ts",
|
||||
built: "index.js",
|
||||
},
|
||||
packageName: "@openclaw/deepgram-media-understanding",
|
||||
packageVersion: "2026.3.14",
|
||||
packageDescription: "OpenClaw Deepgram media-understanding plugin",
|
||||
packageManifest: {
|
||||
extensions: ["./index.ts"],
|
||||
},
|
||||
manifest: {
|
||||
id: "deepgram",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
dirName: "diagnostics-otel",
|
||||
idHint: "diagnostics-otel",
|
||||
@@ -1049,6 +1071,28 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
|
||||
channels: ["googlechat"],
|
||||
},
|
||||
},
|
||||
{
|
||||
dirName: "groq",
|
||||
idHint: "groq-media-understanding",
|
||||
source: {
|
||||
source: "./index.ts",
|
||||
built: "index.js",
|
||||
},
|
||||
packageName: "@openclaw/groq-media-understanding",
|
||||
packageVersion: "2026.3.14",
|
||||
packageDescription: "OpenClaw Groq media-understanding plugin",
|
||||
packageManifest: {
|
||||
extensions: ["./index.ts"],
|
||||
},
|
||||
manifest: {
|
||||
id: "groq",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
dirName: "huggingface",
|
||||
idHint: "huggingface",
|
||||
|
||||
72
test/fixtures/test-parallel.behavior.json
vendored
72
test/fixtures/test-parallel.behavior.json
vendored
@@ -23,50 +23,6 @@
|
||||
}
|
||||
],
|
||||
"threadPinned": [
|
||||
{
|
||||
"file": "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts",
|
||||
"reason": "Measured ~15% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts",
|
||||
"reason": "Measured ~13% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/cron/isolated-agent/run.cron-model-override.test.ts",
|
||||
"reason": "Measured ~25% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/cron/isolated-agent/run.owner-auth.test.ts",
|
||||
"reason": "Measured ~19% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/cron/isolated-agent.auth-profile-propagation.test.ts",
|
||||
"reason": "Measured ~22% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts",
|
||||
"reason": "Measured ~14% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/cron/isolated-agent.subagent-model.test.ts",
|
||||
"reason": "Measured ~21% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/deliver.test.ts",
|
||||
"reason": "Terminates cleanly under threads, but not process forks on this host."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/deliver.lifecycle.test.ts",
|
||||
"reason": "Terminates cleanly under threads, but not process forks on this host."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/message.channels.test.ts",
|
||||
"reason": "Terminates cleanly under threads, but not process forks on this host."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/message-action-runner.poll.test.ts",
|
||||
"reason": "Terminates cleanly under threads, but not process forks on this host."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/message-action-runner.context.test.ts",
|
||||
"reason": "Terminates cleanly under threads, but not process forks on this host."
|
||||
@@ -83,34 +39,6 @@
|
||||
"file": "src/infra/outbound/outbound-policy.test.ts",
|
||||
"reason": "Measured ~11% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/outbound.test.ts",
|
||||
"reason": "Measured ~14% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/web-search-providers.runtime.test.ts",
|
||||
"reason": "Measured ~17% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/media-understanding/runtime.test.ts",
|
||||
"reason": "Measured ~13% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/media-understanding/resolve.test.ts",
|
||||
"reason": "Measured ~9% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/media-understanding/runner.skip-tiny-audio.test.ts",
|
||||
"reason": "Measured ~23% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/media-understanding/runner.proxy.test.ts",
|
||||
"reason": "Measured ~55% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/media-understanding/runner.auto-audio.test.ts",
|
||||
"reason": "Measured ~23% faster under threads than forks on this host while keeping the file green."
|
||||
},
|
||||
{
|
||||
"file": "src/media-understanding/runner.video.test.ts",
|
||||
"reason": "Measured ~25% faster under threads than forks on this host while keeping the file green."
|
||||
|
||||
Reference in New Issue
Block a user