mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: preserve message route and account defaults
This commit is contained in:
@@ -57,6 +57,26 @@ describe("resolveFeishuToolAccount", () => {
|
||||
expect(resolved.accountId).toBe("work");
|
||||
});
|
||||
|
||||
it("allows the explicit unlisted default backed by top-level credentials", () => {
|
||||
const resolved = resolveFeishuToolAccount({
|
||||
api: {
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "ops",
|
||||
appId: "base-app-id",
|
||||
appSecret: "base-app-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
executeParams: { accountId: "OPS" },
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("ops");
|
||||
expect(resolved.configured).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "malformed", accountId: "!!!", error: "Invalid Feishu account ID" },
|
||||
{ name: "unknown", accountId: "missing", error: "Unknown Feishu account" },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import {
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
resolveFeishuRuntimeAccount,
|
||||
} from "./accounts.js";
|
||||
@@ -31,9 +32,16 @@ function resolveImplicitToolAccountId(params: {
|
||||
if (!normalizedAccountId) {
|
||||
throw new Error(`Invalid Feishu account ID "${explicitAccountId}"`);
|
||||
}
|
||||
const listedAccountId = listFeishuAccountIds(params.api.config).find(
|
||||
(accountId) => normalizeOptionalAccountId(accountId) === normalizedAccountId,
|
||||
);
|
||||
const listedAccountId =
|
||||
listFeishuAccountIds(params.api.config).find(
|
||||
(accountId) => normalizeOptionalAccountId(accountId) === normalizedAccountId,
|
||||
) ??
|
||||
(() => {
|
||||
const defaultAccountId = resolveDefaultFeishuAccountId(params.api.config);
|
||||
return normalizeOptionalAccountId(defaultAccountId) === normalizedAccountId
|
||||
? defaultAccountId
|
||||
: undefined;
|
||||
})();
|
||||
if (!listedAccountId) {
|
||||
throw new Error(`Unknown Feishu account "${explicitAccountId}"`);
|
||||
}
|
||||
|
||||
@@ -1034,8 +1034,9 @@ describe("gateway send mirroring", () => {
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(mocks.dispatchChannelMessageAction.mock.calls[0]?.[0]).toMatchObject({
|
||||
conversationReadOrigin: "direct-operator",
|
||||
});
|
||||
@@ -1441,6 +1442,85 @@ describe("gateway send mirroring", () => {
|
||||
expect(firstRespondCall(retryRespond)?.[3]?.cached).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "send",
|
||||
method: "send" as const,
|
||||
request: {
|
||||
to: "channel:C1",
|
||||
message: "hi",
|
||||
idempotencyKey: "idem-send-deferred-route-race",
|
||||
},
|
||||
providerCall: mocks.deliverOutboundPayloads,
|
||||
},
|
||||
{
|
||||
name: "poll",
|
||||
method: "poll" as const,
|
||||
request: {
|
||||
to: "channel:C1",
|
||||
question: "Q?",
|
||||
options: ["A", "B"],
|
||||
idempotencyKey: "idem-poll-deferred-route-race",
|
||||
},
|
||||
providerCall: mocks.sendPoll,
|
||||
},
|
||||
])(
|
||||
"keeps the first deferred $name route when a concurrent retry sees newer defaults",
|
||||
async (testCase) => {
|
||||
const firstSelection = createDeferred<{ channel: string; configured: string[] }>();
|
||||
mocks.resolveMessageChannelSelection
|
||||
.mockImplementationOnce(async () => await firstSelection.promise)
|
||||
.mockResolvedValue({ channel: "discord", configured: ["discord"] });
|
||||
mockMutableMessageRouteAccounts(() => "primary");
|
||||
|
||||
const providerDeferred = createDeferred<unknown>();
|
||||
if (testCase.method === "send") {
|
||||
mocks.deliverOutboundPayloads.mockReturnValueOnce(providerDeferred.promise as never);
|
||||
} else {
|
||||
mocks.sendPoll.mockReturnValueOnce(providerDeferred.promise as never);
|
||||
}
|
||||
|
||||
const context = makeContext();
|
||||
const firstRespond = vi.fn();
|
||||
const retryRespond = vi.fn();
|
||||
const firstRequest = invokeGatewayMessageMethod({
|
||||
method: testCase.method,
|
||||
request: testCase.request,
|
||||
respond: firstRespond,
|
||||
context,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const retryRequest = invokeGatewayMessageMethod({
|
||||
method: testCase.method,
|
||||
request: testCase.request,
|
||||
respond: retryRespond,
|
||||
context,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstSelection.resolve({ channel: "slack", configured: ["slack"] });
|
||||
await vi.waitFor(() => {
|
||||
expect(testCase.providerCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
if (testCase.method === "send") {
|
||||
providerDeferred.resolve([{ messageId: "m-race", channel: "slack" }]);
|
||||
} else {
|
||||
providerDeferred.resolve({ messageId: "poll-race", pollId: "poll-race" });
|
||||
}
|
||||
await Promise.all([firstRequest, retryRequest]);
|
||||
|
||||
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledTimes(1);
|
||||
expect(testCase.providerCall).toHaveBeenCalledTimes(1);
|
||||
expect(firstRespondCall(firstRespond)?.[0]).toBe(true);
|
||||
expect(firstRespondCall(retryRespond)?.[0]).toBe(true);
|
||||
expect(firstRespondCall(retryRespond)?.[3]?.cached).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("dedupes omitted and explicit default poll routes", async () => {
|
||||
const context = makeContext();
|
||||
const firstRespond = vi.fn();
|
||||
|
||||
+672
-563
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { validateExplicitMessageAccountSelection } from "./message-account-selection.js";
|
||||
|
||||
describe("validateExplicitMessageAccountSelection", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const plugin = {
|
||||
id: "feishu",
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
defaultAccountId: () => "ops",
|
||||
resolveAccount: (_cfg: OpenClawConfig, accountId?: string | null) => ({
|
||||
accountId,
|
||||
enabled: true,
|
||||
}),
|
||||
},
|
||||
} as unknown as ChannelPlugin;
|
||||
|
||||
it("accepts the plugin-resolved default when it is intentionally unlisted", () => {
|
||||
expect(
|
||||
validateExplicitMessageAccountSelection({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "OPS",
|
||||
plugin,
|
||||
}),
|
||||
).toBe("ops");
|
||||
});
|
||||
|
||||
it("still rejects a non-default unlisted account", () => {
|
||||
expect(() =>
|
||||
validateExplicitMessageAccountSelection({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "missing",
|
||||
plugin,
|
||||
}),
|
||||
).toThrow('Unknown account "missing"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveChannelAccountEnabled } from "../../channels/account-summary.js";
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
@@ -21,9 +22,19 @@ function resolveListedAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): string | undefined {
|
||||
return params.plugin.config
|
||||
const listedAccountId = params.plugin.config
|
||||
.listAccountIds(params.cfg)
|
||||
.find((candidate) => normalizeOptionalAccountId(candidate) === params.accountId);
|
||||
if (listedAccountId) {
|
||||
return listedAccountId;
|
||||
}
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin: params.plugin,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
return normalizeOptionalAccountId(defaultAccountId) === params.accountId
|
||||
? defaultAccountId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isExplicitAccountDisabled(params: {
|
||||
|
||||
Reference in New Issue
Block a user